Size
238.8 KB
Version
1.8.58
Created
Mar 27, 2026
Updated
20 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="send-to-dashboard-btn" style="background: linear-gradient(135deg, #6c5ce7 0%, #a29bfe 100%); margin-right: 10px;">📡 Отправить в дашборд</button>
996 <button class="ozon-parser-btn" id="close-prices-modal">Закрыть</button>
997 </div>
998 `;
999
1000 modal.appendChild(content);
1001 document.body.appendChild(modal);
1002
1003 // Начальное отображение
1004 let currentMethod = 'max';
1005 let currentFilters = {
1006 revenue: 0,
1007 revenueDirection: 'less',
1008 currentPrice: 0,
1009 currentPriceDirection: 'more',
1010 avgPrice: 0,
1011 avgPriceDirection: 'more',
1012 querySearch: '',
1013 skuSearch: '',
1014 selectedQuery: ''
1015 };
1016
1017 // Загружаем сохраненный запрос
1018 const savedQuery = await GM.getValue('ozon_parser_selected_query', '');
1019 if (savedQuery) {
1020 currentFilters.selectedQuery = savedQuery;
1021 }
1022
1023 displayPricesTable(currentMethod, currentFilters);
1024
1025 // Обновляем информационную панель после первого рендера
1026 if (savedQuery) {
1027 const selectedQueryInfo = content.querySelector('#selected-query-info');
1028 const selectedQueryText = content.querySelector('#selected-query-text');
1029 if (selectedQueryInfo && selectedQueryText) {
1030 selectedQueryInfo.style.display = 'block';
1031 selectedQueryText.textContent = savedQuery;
1032 }
1033 }
1034
1035 // Функция для обновления фильтров из полей ввода
1036 function updateFiltersFromInputs() {
1037 currentFilters = {
1038 revenue: parseFloat(content.querySelector('#filter-revenue').value) || 0,
1039 revenueDirection: content.querySelector('#filter-revenue-direction').value,
1040 currentPrice: parseFloat(content.querySelector('#filter-current-price').value) || 0,
1041 currentPriceDirection: content.querySelector('#filter-current-price-direction').value,
1042 avgPrice: parseFloat(content.querySelector('#filter-avg-price').value) || 0,
1043 avgPriceDirection: content.querySelector('#filter-avg-price-direction').value,
1044 querySearch: content.querySelector('#filter-query-search').value.trim(),
1045 skuSearch: content.querySelector('#filter-sku-search').value.trim(),
1046 selectedQuery: currentFilters.selectedQuery
1047 };
1048 displayPricesTable(currentMethod, currentFilters);
1049 }
1050
1051 // Обработчик очистки фильтра по запросу
1052 const clearQueryFilterBtn = content.querySelector('#clear-query-filter');
1053 if (clearQueryFilterBtn) {
1054 clearQueryFilterBtn.addEventListener('click', async () => {
1055 currentFilters.selectedQuery = '';
1056 await GM.setValue('ozon_parser_selected_query', '');
1057 const selectedQueryInfo = content.querySelector('#selected-query-info');
1058 if (selectedQueryInfo) {
1059 selectedQueryInfo.style.display = 'none';
1060 }
1061 displayPricesTable(currentMethod, currentFilters);
1062 });
1063 }
1064
1065 // Обработчик изменения метода расчета
1066 const methodSelector = content.querySelector('#price-method-selector');
1067 methodSelector.addEventListener('change', () => {
1068 currentMethod = methodSelector.value;
1069 displayPricesTable(currentMethod, currentFilters);
1070 });
1071
1072 // Обработчики для всех фильтров - применяем автоматически
1073 content.querySelector('#filter-query-search').addEventListener('input', debounce(updateFiltersFromInputs, 300));
1074 content.querySelector('#filter-sku-search').addEventListener('input', debounce(updateFiltersFromInputs, 300));
1075
1076 content.querySelector('#filter-revenue').addEventListener('input', debounce(updateFiltersFromInputs, 500));
1077 content.querySelector('#filter-revenue-direction').addEventListener('change', updateFiltersFromInputs);
1078
1079 content.querySelector('#filter-current-price').addEventListener('input', debounce(updateFiltersFromInputs, 500));
1080 content.querySelector('#filter-current-price-direction').addEventListener('change', updateFiltersFromInputs);
1081
1082 content.querySelector('#filter-avg-price').addEventListener('input', debounce(updateFiltersFromInputs, 500));
1083 content.querySelector('#filter-avg-price-direction').addEventListener('change', updateFiltersFromInputs);
1084
1085 // Обработчик экспорта в CSV
1086 content.querySelector('#export-csv-btn').addEventListener('click', () => {
1087 exportToCSV(ourProductsData, currentMethod, currentFilters);
1088 });
1089 // Обработчик отправки в дашборд
1090content.querySelector('#send-to-dashboard-btn').addEventListener('click', async () => {
1091 const btn = content.querySelector('#send-to-dashboard-btn');
1092 btn.disabled = true;
1093 btn.textContent = '⏳ Отправка...';
1094
1095 try {
1096 // Формируем данные для отправки
1097 const dataToSend = ourProductsData.map(item => {
1098 // Получаем текущий метод из селектора
1099 const methodSelector = content.querySelector('#price-method-selector');
1100 const selectedMethod = methodSelector ? methodSelector.value : 'max';
1101
1102 // Выбираем цену конкурента по методу
1103 let competitorPriceCurrent;
1104 switch(selectedMethod) {
1105 case 'max': competitorPriceCurrent = item.competitorMaxPrice; break;
1106 case 'avg': competitorPriceCurrent = item.competitorAvgPrice; break;
1107 case 'weighted': competitorPriceCurrent = item.competitorWeightedPrice; break;
1108 case 'min': competitorPriceCurrent = item.competitorMinPrice; break;
1109 case 'median': competitorPriceCurrent = item.competitorMedianPrice; break;
1110 default: competitorPriceCurrent = item.competitorMaxPrice;
1111 }
1112
1113 return {
1114 sku: String(item.sku),
1115 title: String(item.name || 'Без названия'),
1116 query: String(item.query || 'неизвестно'),
1117 list: String(item.listName || 'Без названия'),
1118 our_price_current: Math.round(item.ourCurrentPrice || 0),
1119 competitor_price_current: Math.round(competitorPriceCurrent || 0),
1120 delta_current_percent: parseFloat((((item.ourCurrentPrice - competitorPriceCurrent) / Math.max(competitorPriceCurrent || 1, 1)) * 100).toFixed(1)),
1121 our_price_avg: Math.round(item.ourAvgPrice || 0),
1122 competitor_price_avg: Math.round(item.competitorAvgPriceValue || item.competitorAvgPrice || 0),
1123 delta_avg_percent: parseFloat((((item.ourAvgPrice - (item.competitorAvgPriceValue || item.competitorAvgPrice || 0)) / Math.max(item.competitorAvgPriceValue || item.competitorAvgPrice || 1, 1)) * 100).toFixed(1)),
1124 our_revenue: Math.round(item.ourRevenue || 0),
1125 competitor_revenue: Math.round(item.competitorRevenue || 0),
1126 delta_revenue_percent: parseFloat((((item.ourRevenue - item.competitorRevenue) / Math.max(item.competitorRevenue || 1, 1)) * 100).toFixed(1)),
1127 parsed_at: new Date().toISOString(),
1128 method_used: selectedMethod // для отладки
1129 };
1130});
1131
1132 // Отправляем на Blink
1133 const response = await fetch('https://8p6h747w--products.functions.blink.new/products', {
1134 method: 'POST',
1135 headers: {
1136 'Content-Type': 'application/json',
1137 'X-API-Key': 'secret123'
1138 },
1139 body: JSON.stringify(dataToSend)
1140 });
1141
1142 if (!response.ok) {
1143 throw new Error(`HTTP ${response.status}: ${response.statusText}`);
1144 }
1145
1146 const result = await response.json();
1147 btn.textContent = '✅ Отправлено!';
1148 btn.style.background = 'linear-gradient(135deg, #00b894 0%, #00cec9 100%)';
1149
1150 alert(`Успешно отправлено ${dataToSend.length} товаров в дашборд!\n\nОтвет сервера: ${JSON.stringify(result, null, 2)}`);
1151
1152 setTimeout(() => {
1153 btn.disabled = false;
1154 btn.textContent = '📡 Отправить в дашборд';
1155 btn.style.background = 'linear-gradient(135deg, #6c5ce7 0%, #a29bfe 100%)';
1156 }, 3000);
1157
1158 } catch (error) {
1159 console.error('Ozon Product Parser: Error sending to dashboard:', error);
1160 btn.textContent = '❌ Ошибка';
1161 btn.style.background = 'linear-gradient(135deg, #e74c3c 0%, #c0392b 100%)';
1162
1163 alert('Ошибка при отправке в дашборд:\n\n' + error.message + '\n\nПроверьте:\n1. URL дашборда (возможно он изменился)\n2. Подключение к интернету\n3. CORS политику (для разработки)');
1164
1165 setTimeout(() => {
1166 btn.disabled = false;
1167 btn.textContent = '📡 Отправить в дашборд';
1168 btn.style.background = 'linear-gradient(135deg, #6c5ce7 0%, #a29bfe 100%)';
1169 }, 3000);
1170 }
1171});
1172
1173 // Обработчик закрытия
1174 content.querySelector('#close-prices-modal').addEventListener('click', () => {
1175 modal.remove();
1176 });
1177
1178 modal.addEventListener('click', (e) => {
1179 if (e.target === modal) {
1180 modal.remove();
1181 }
1182 });
1183
1184 console.log('Ozon Product Parser: Prices modal shown');
1185 } catch (error) {
1186 console.error('Ozon Product Parser: Error in showPricesModal:', error);
1187 alert('Ошибка при отображении цен: ' + error.message);
1188 }
1189 }
1190
1191 // Показываем модальное окно выбора запроса для товара
1192 async function showQuerySelectionModal(sku, allListResults, skuQueryBindings) {
1193 console.log('Ozon Product Parser: Opening query selection modal for SKU:', sku);
1194
1195 // Собираем все запросы, где встречается этот товар
1196 const queriesWithProduct = [];
1197 for (const listName in allListResults) {
1198 const listData = allListResults[listName];
1199 for (const query in listData.queries) {
1200 const products = listData.queries[query];
1201 const product = products.find(p => p.sku === sku);
1202 if (product) {
1203 // Находим конкурентов для этого запроса
1204 const competitors = products.filter(p => !p.isTargetBrand && p.price > 0);
1205 const sortedCompetitors = [...competitors].sort((a, b) => a.position - b.position);
1206 const top3Competitors = sortedCompetitors.slice(0, 3);
1207
1208 if (top3Competitors.length > 0) {
1209 const competitorPrices = top3Competitors.map(c => c.price);
1210 const competitorRevenues = top3Competitors.map(c => c.revenue);
1211
1212 const competitorMaxPrice = Math.max(...competitorPrices);
1213 const competitorAvgPrice = competitorPrices.reduce((sum, p) => sum + p, 0) / competitorPrices.length;
1214 const competitorRevenue = competitorRevenues.reduce((sum, r) => sum + r, 0) / competitorRevenues.length;
1215
1216 queriesWithProduct.push({
1217 listName: listName,
1218 query: query,
1219 product: product,
1220 competitorMaxPrice: competitorMaxPrice,
1221 competitorAvgPrice: competitorAvgPrice,
1222 competitorRevenue: competitorRevenue
1223 });
1224 }
1225 }
1226 }
1227 }
1228
1229 if (queriesWithProduct.length === 0) {
1230 alert('Товар не найден ни в одном запросе');
1231 return;
1232 }
1233
1234 const modal = document.createElement('div');
1235 modal.className = 'ozon-parser-modal';
1236 modal.style.zIndex = '10003';
1237
1238 const content = document.createElement('div');
1239 content.className = 'ozon-parser-modal-content';
1240 content.style.maxWidth = '900px';
1241
1242 const currentBoundQuery = skuQueryBindings[sku];
1243
1244 let modalHTML = `
1245 <div class="ozon-parser-modal-header">Выбор запроса для SKU ${sku}</div>
1246 <div class="ozon-parser-modal-body">
1247 <div class="ozon-parser-info">
1248 Нажмите на значок замка, чтобы закрепить запрос для этого товара.
1249 ${currentBoundQuery ? `<br><strong>Текущая привязка:</strong> ${currentBoundQuery}` : ''}
1250 </div>
1251 <table class="ozon-parser-results-table">
1252 <thead>
1253 <tr>
1254 <th style="width: 40px;"></th>
1255 <th>Запрос</th>
1256 <th>Список</th>
1257 <th>Позиция</th>
1258 <th>Выручка товара</th>
1259 <th>Макс. цена конкурентов</th>
1260 <th>Средняя цена конкурентов</th>
1261 <th>Средняя выручка конкурентов</th>
1262 </tr>
1263 </thead>
1264 <tbody>
1265 `;
1266
1267 queriesWithProduct.forEach(item => {
1268 const isBound = currentBoundQuery === item.query;
1269 const rowStyle = isBound ? 'background: #d4edda;' : '';
1270 const lockIcon = isBound ? '🔒' : '🔓';
1271 const lockColor = isBound ? '#28a745' : '#999';
1272
1273 modalHTML += `
1274 <tr style="${rowStyle}">
1275 <td style="text-align: center;">
1276 <span class="query-lock-icon" data-query="${item.query}" style="cursor: pointer; font-size: 20px; color: ${lockColor}; user-select: none;" title="${isBound ? 'Отвязать запрос' : 'Закрепить запрос'}">${lockIcon}</span>
1277 </td>
1278 <td style="font-weight: ${isBound ? '600' : 'normal'};">${item.query}</td>
1279 <td>${item.listName}</td>
1280 <td>${item.product.position}</td>
1281 <td>${Math.round(item.product.revenue).toLocaleString('ru-RU')} ₽</td>
1282 <td>${Math.round(item.competitorMaxPrice).toLocaleString('ru-RU')} ₽</td>
1283 <td>${Math.round(item.competitorAvgPrice).toLocaleString('ru-RU')} ₽</td>
1284 <td>${Math.round(item.competitorRevenue).toLocaleString('ru-RU')} ₽</td>
1285 </tr>
1286 `;
1287 });
1288
1289 modalHTML += `
1290 </tbody>
1291 </table>
1292 </div>
1293 <div class="ozon-parser-modal-footer">
1294 <button class="ozon-parser-btn" id="close-query-selection-modal">Закрыть</button>
1295 </div>
1296 `;
1297
1298 content.innerHTML = modalHTML;
1299 modal.appendChild(content);
1300 document.body.appendChild(modal);
1301
1302 // Обработчики кликов на замки
1303 content.querySelectorAll('.query-lock-icon').forEach(lockIcon => {
1304 lockIcon.addEventListener('click', async () => {
1305 const selectedQuery = lockIcon.getAttribute('data-query');
1306 const isBound = currentBoundQuery === selectedQuery;
1307
1308 if (isBound) {
1309 // Отвязываем запрос
1310 if (confirm(`Отвязать запрос "${selectedQuery}" от товара ${sku}?`)) {
1311 delete skuQueryBindings[sku];
1312 await GM.setValue('ozon_parser_sku_query_bindings', JSON.stringify(skuQueryBindings));
1313
1314 console.log(`Ozon Product Parser: Unbound SKU ${sku} from query "${selectedQuery}"`);
1315
1316 modal.remove();
1317
1318 // Перезагружаем таблицу цен
1319 await showPricesModal();
1320 }
1321 } else {
1322 // Привязываем запрос
1323 skuQueryBindings[sku] = selectedQuery;
1324 await GM.setValue('ozon_parser_sku_query_bindings', JSON.stringify(skuQueryBindings));
1325
1326 console.log(`Ozon Product Parser: Bound SKU ${sku} to query "${selectedQuery}"`);
1327
1328 modal.remove();
1329
1330 // Перезагружаем таблицу цен
1331 await showPricesModal();
1332 }
1333 });
1334 });
1335
1336 // Закрытие
1337 content.querySelector('#close-query-selection-modal').addEventListener('click', () => {
1338 modal.remove();
1339 });
1340
1341 modal.addEventListener('click', (e) => {
1342 if (e.target === modal) {
1343 modal.remove();
1344 }
1345 });
1346 }
1347
1348 // Показываем панель с деталями товара
1349 async function showProductDetailsPanel(productData, allListResults) {
1350 console.log('Ozon Product Parser: Opening product details panel for SKU:', productData.sku);
1351
1352 // Удаляем существующую панель, если она есть
1353 const existingPanel = document.querySelector('.ozon-parser-details-panel');
1354 if (existingPanel) {
1355 existingPanel.remove();
1356 }
1357
1358 // Создаем панель
1359 const panel = document.createElement('div');
1360 panel.className = 'ozon-parser-details-panel';
1361
1362 // Загружаем привязки SKU к запросам
1363 const skuQueryBindingsJson = await GM.getValue('ozon_parser_sku_query_bindings', '{}');
1364 const skuQueryBindings = JSON.parse(skuQueryBindingsJson);
1365 const boundQuery = skuQueryBindings[productData.sku];
1366
1367 // Собираем все запросы, где встречается этот товар
1368 const queriesWithProduct = [];
1369 for (const listName in allListResults) {
1370 const listData = allListResults[listName];
1371 for (const query in listData.queries) {
1372 const products = listData.queries[query];
1373 const product = products.find(p => p.sku === productData.sku);
1374 if (product) {
1375 // Если есть привязка и это не тот запрос - пропускаем
1376 if (boundQuery && boundQuery !== query) {
1377 continue;
1378 }
1379
1380 queriesWithProduct.push({
1381 listName: listName,
1382 query: query,
1383 product: product,
1384 allProducts: products
1385 });
1386 }
1387 }
1388 }
1389
1390 // Функция для генерации HTML панели с аналитикой
1391 async function generatePanelHTML(queryData) {
1392 const analytics = await analyzeProducts(queryData.allProducts);
1393
1394 return `
1395 <div class="ozon-parser-details-panel-header">
1396 <div class="ozon-parser-details-panel-title">SKU: ${productData.sku}</div>
1397 <button class="ozon-parser-details-panel-close">Закрыть</button>
1398 </div>
1399 <div class="ozon-parser-details-panel-body">
1400 <div style="margin-bottom: 20px;">
1401 <div style="font-size: 16px; font-weight: 600; margin-bottom: 10px;">${productData.name || 'Название не найдено'}</div>
1402 <div style="font-size: 14px; color: #666; margin-bottom: 5px;">Текущая цена: <strong>${Math.round(productData.ourCurrentPrice).toLocaleString('ru-RU')} ₽</strong></div>
1403 <div style="font-size: 14px; color: #666; margin-bottom: 5px;">Средняя цена: <strong>${Math.round(productData.ourAvgPrice).toLocaleString('ru-RU')} ₽</strong></div>
1404 <div style="font-size: 14px; color: #666;">Выручка: <strong>${Math.round(productData.ourRevenue).toLocaleString('ru-RU')} ₽</strong></div>
1405 </div>
1406
1407 ${analytics && analytics.elasticity ? `
1408 <div style="margin-bottom: 20px; padding: 15px; background: #e7f3ff; border-radius: 8px; border-left: 4px solid #005bff;">
1409 <div style="font-size: 15px; font-weight: 600; margin-bottom: 10px; color: #005bff;">📊 Эластичность запроса</div>
1410 <div style="font-size: 24px; font-weight: 700; color: #333; margin-bottom: 8px;">${analytics.elasticity.value}</div>
1411 <div style="font-size: 13px; color: #666; line-height: 1.5;">
1412 ${analytics.elasticity.interpretation}
1413 </div>
1414 </div>
1415 ` : ''}
1416
1417 ${analytics && analytics.recommendedPrices ? `
1418 <div style="margin-bottom: 20px;">
1419 <div style="font-size: 15px; font-weight: 600; margin-bottom: 12px; color: #005bff;">💰 Стратегии ценообразования</div>
1420
1421 <div style="background: #f8f9fa; padding: 12px; border-radius: 8px; margin-bottom: 10px; border-left: 4px solid #6c757d;">
1422 <div style="font-size: 14px; font-weight: 600; color: #6c757d; margin-bottom: 5px;">⚡ Захват рынка</div>
1423 <div style="font-size: 20px; font-weight: 700; color: #333; margin-bottom: 5px;">${analytics.recommendedPrices.marketCapture.price.toLocaleString('ru-RU')} ₽</div>
1424 <div style="font-size: 12px; color: #666; margin-bottom: 8px;">${analytics.recommendedPrices.marketCapture.description}</div>
1425 ${analytics.brandRecommendations && (analytics.brandRecommendations.gls || analytics.brandRecommendations.skinphoria) ? `
1426 <div style="font-size: 11px; color: #666; margin-top: 8px; padding-top: 8px; border-top: 1px solid #e0e0e0;">
1427 ${(() => {
1428 const ourProduct = (analytics.brandRecommendations.gls?.currentProducts || [])
1429 .concat(analytics.brandRecommendations.skinphoria?.currentProducts || [])
1430 .find(p => p.sku === productData.sku);
1431 if (!ourProduct || !ourProduct.recommendations || !ourProduct.recommendations.forecast || !ourProduct.recommendations.forecast.marketCapture) return '';
1432 const forecast = ourProduct.recommendations.forecast.marketCapture;
1433 return `
1434 <div style="color: ${forecast.revenueChange >= 0 ? '#28a745' : '#dc3545'};">Выручка: ${forecast.revenueChange > 0 ? '+' : ''}${forecast.revenueChange}%</div>
1435 <div style="color: ${forecast.ordersChange >= 0 ? '#28a745' : '#dc3545'};">Заказы: ${forecast.ordersChange > 0 ? '+' : ''}${forecast.ordersChange}%</div>
1436 ${forecast.profit !== null ? `<div style="color: ${forecast.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600;">Прибыль: ${forecast.profitChange > 0 ? '+' : ''}${forecast.profitChange}% (${forecast.profit.toFixed(0)} ₽)</div>` : ''}
1437 `;
1438 })()}
1439 </div>
1440 ` : ''}
1441 </div>
1442
1443 <div style="background: #fff3cd; padding: 12px; border-radius: 8px; margin-bottom: 10px; border-left: 4px solid #dc3545;">
1444 <div style="font-size: 14px; font-weight: 600; color: #dc3545; margin-bottom: 5px;">✅ Оптимальная</div>
1445 <div style="font-size: 20px; font-weight: 700; color: #333; margin-bottom: 5px;">${analytics.recommendedPrices.aggressive.price.toLocaleString('ru-RU')} ₽</div>
1446 <div style="font-size: 12px; color: #666; margin-bottom: 8px;">${analytics.recommendedPrices.aggressive.description}</div>
1447 ${analytics.brandRecommendations && (analytics.brandRecommendations.gls || analytics.brandRecommendations.skinphoria) ? `
1448 <div style="font-size: 11px; color: #666; margin-top: 8px; padding-top: 8px; border-top: 1px solid #e0e0e0;">
1449 ${(() => {
1450 const ourProduct = (analytics.brandRecommendations.gls?.currentProducts || [])
1451 .concat(analytics.brandRecommendations.skinphoria?.currentProducts || [])
1452 .find(p => p.sku === productData.sku);
1453 if (!ourProduct || !ourProduct.recommendations || !ourProduct.recommendations.forecast || !ourProduct.recommendations.forecast.aggressive) return '';
1454 const forecast = ourProduct.recommendations.forecast.aggressive;
1455 return `
1456 <div style="color: ${forecast.revenueChange >= 0 ? '#28a745' : '#dc3545'};">Выручка: ${forecast.revenueChange > 0 ? '+' : ''}${forecast.revenueChange}%</div>
1457 <div style="color: ${forecast.ordersChange >= 0 ? '#28a745' : '#dc3545'};">Заказы: ${forecast.ordersChange > 0 ? '+' : ''}${forecast.ordersChange}%</div>
1458 ${forecast.profit !== null ? `<div style="color: ${forecast.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600;">Прибыль: ${forecast.profitChange > 0 ? '+' : ''}${forecast.profitChange}% (${forecast.profit.toFixed(0)} ₽)</div>` : ''}
1459 `;
1460 })()}
1461 </div>
1462 ` : ''}
1463 </div>
1464
1465 <div style="background: #d4edda; padding: 12px; border-radius: 8px; border-left: 4px solid #28a745;">
1466 <div style="font-size: 14px; font-weight: 600; color: #28a745; margin-bottom: 5px;">🔥 Агрессивная</div>
1467 <div style="font-size: 20px; font-weight: 700; color: #333; margin-bottom: 5px;">${analytics.recommendedPrices.optimal.price.toLocaleString('ru-RU')} ₽</div>
1468 <div style="font-size: 12px; color: #666; margin-bottom: 8px;">${analytics.recommendedPrices.optimal.description}</div>
1469 ${analytics.brandRecommendations && (analytics.brandRecommendations.gls || analytics.brandRecommendations.skinphoria) ? `
1470 <div style="font-size: 11px; color: #666; margin-top: 8px; padding-top: 8px; border-top: 1px solid #e0e0e0;">
1471 ${(() => {
1472 const ourProduct = (analytics.brandRecommendations.gls?.currentProducts || [])
1473 .concat(analytics.brandRecommendations.skinphoria?.currentProducts || [])
1474 .find(p => p.sku === productData.sku);
1475 if (!ourProduct || !ourProduct.recommendations || !ourProduct.recommendations.forecast || !ourProduct.recommendations.forecast.optimal) return '';
1476 const forecast = ourProduct.recommendations.forecast.optimal;
1477 return `
1478 <div style="color: ${forecast.revenueChange >= 0 ? '#28a745' : '#dc3545'};">Выручка: ${forecast.revenueChange > 0 ? '+' : ''}${forecast.revenueChange}%</div>
1479 <div style="color: ${forecast.ordersChange >= 0 ? '#28a745' : '#dc3545'};">Заказы: ${forecast.ordersChange > 0 ? '+' : ''}${forecast.ordersChange}%</div>
1480 ${forecast.profit !== null ? `<div style="color: ${forecast.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600;">Прибыль: ${forecast.profitChange > 0 ? '+' : ''}${forecast.profitChange}% (${forecast.profit.toFixed(0)} ₽)</div>` : ''}
1481 `;
1482 })()}
1483 </div>
1484 ` : ''}
1485 </div>
1486 </div>
1487 ` : ''}
1488
1489 <div style="margin-bottom: 20px;">
1490 <div style="font-size: 15px; font-weight: 600; margin-bottom: 10px; color: #005bff;">Запросы с этим товаром (${queriesWithProduct.length})</div>
1491 <select class="ozon-parser-search" id="query-selector-panel" style="margin-bottom: 15px;">
1492 ${queriesWithProduct.map((item, index) => `
1493 <option value="${index}">${item.query} (${item.listName})</option>
1494 `).join('')}
1495 </select>
1496 <div id="query-details-container"></div>
1497 </div>
1498 </div>
1499 `;
1500 }
1501
1502 // Получаем аналитику для первого запроса и формируем HTML
1503 const firstQueryData = queriesWithProduct[0];
1504 const initialHTML = await generatePanelHTML(firstQueryData);
1505
1506 panel.innerHTML = initialHTML;
1507 document.body.appendChild(panel);
1508
1509 // Функция для отображения деталей выбранного запроса
1510 function displayQueryDetails(queryIndex) {
1511 const queryData = queriesWithProduct[queryIndex];
1512 const product = queryData.product;
1513 const allProducts = queryData.allProducts;
1514 const query = queryData.query;
1515
1516 // Находим топ-3 конкурентов по выручке
1517 const competitors = allProducts.filter(p => !p.isTargetBrand && p.price > 0 && p.revenue > 0);
1518 const sortedCompetitors = [...competitors].sort((a, b) => b.revenue - a.revenue);
1519 const top3Competitors = sortedCompetitors.slice(0, 3);
1520
1521 // Позиция товара по выручке
1522 const sortedByRevenue = [...allProducts].sort((a, b) => b.revenue - a.revenue);
1523 const revenuePosition = sortedByRevenue.findIndex(p => p.sku === product.sku) + 1;
1524
1525 // URL для поиска на Ozon
1526 const searchUrl = `https://www.ozon.ru/search/?text=${encodeURIComponent(query)}&from_global=true`;
1527
1528 let detailsHTML = `
1529 <div style="padding: 15px; background: #f8f9fa; border-radius: 8px; margin-bottom: 15px;">
1530 <div style="font-size: 14px; font-weight: 600; margin-bottom: 10px;">Позиции в выдаче</div>
1531 <div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px;">
1532 <div>
1533 <div style="font-size: 12px; color: #666;">По позиции</div>
1534 <div style="font-size: 18px; font-weight: 700; color: #005bff;">${product.position} место</div>
1535 </div>
1536 <div>
1537 <div style="font-size: 12px; color: #666;">По выручке</div>
1538 <div style="font-size: 18px; font-weight: 700; color: #005bff;">${revenuePosition} место</div>
1539 </div>
1540 </div>
1541 <div style="margin-top: 10px;">
1542 <a href="${searchUrl}" target="_blank" class="ozon-parser-sku-link" style="font-size: 13px;">🔍 Открыть запрос на Ozon</a>
1543 </div>
1544 </div>
1545
1546 <div style="padding: 15px; background: #f8f9fa; border-radius: 8px; margin-bottom: 15px;">
1547 <div style="font-size: 14px; font-weight: 600; margin-bottom: 10px;">Метрики товара</div>
1548 <div style="font-size: 13px; color: #666; margin-bottom: 5px;">Цена: <strong>${product.price.toLocaleString('ru-RU')} ₽</strong></div>
1549 <div style="font-size: 13px; color: #666; margin-bottom: 5px;">Выручка: <strong>${product.revenue.toLocaleString('ru-RU')} ₽</strong></div>
1550 <div style="font-size: 13px; color: #666;">Заказы: <strong>${product.orders.toLocaleString('ru-RU')}</strong></div>
1551 </div>
1552 `;
1553
1554 if (top3Competitors.length > 0) {
1555 detailsHTML += `
1556 <div style="padding: 15px; background: #fff3cd; border-radius: 8px;">
1557 <div style="font-size: 14px; font-weight: 600; margin-bottom: 10px;">Топ-3 конкурента по выручке</div>
1558 <table class="ozon-parser-results-table" style="margin-top: 0;">
1559 <thead>
1560 <tr>
1561 <th>Позиция</th>
1562 <th>Бренд</th>
1563 <th>Текущая цена</th>
1564 <th>Средняя цена</th>
1565 <th>Выручка</th>
1566 <th>Заказы</th>
1567 </tr>
1568 </thead>
1569 <tbody>
1570 `;
1571
1572 top3Competitors.forEach(comp => {
1573 const avgPrice = comp.orders > 0 ? comp.revenue / comp.orders : 0;
1574 detailsHTML += `
1575 <tr>
1576 <td>${comp.position}</td>
1577 <td><a href="https://www.ozon.ru/product/${comp.sku}" target="_blank" class="ozon-parser-sku-link">${comp.brand || '—'}</a></td>
1578 <td>${comp.price.toLocaleString('ru-RU')} ₽</td>
1579 <td>${Math.round(avgPrice).toLocaleString('ru-RU')} ₽</td>
1580 <td>${comp.revenue.toLocaleString('ru-RU')} ₽</td>
1581 <td>${comp.orders.toLocaleString('ru-RU')}</td>
1582 </tr>
1583 `;
1584 });
1585
1586 detailsHTML += `
1587 </tbody>
1588 </table>
1589 </div>
1590 `;
1591 }
1592
1593 const container = panel.querySelector('#query-details-container');
1594 container.innerHTML = detailsHTML;
1595 }
1596
1597 // Функция для обновления панели при смене запроса
1598 async function updatePanelForQuery(queryIndex) {
1599 const selectedQueryData = queriesWithProduct[queryIndex];
1600
1601 // Пересчитываем аналитику для выбранного запроса
1602 const newHTML = await generatePanelHTML(selectedQueryData);
1603 panel.innerHTML = newHTML;
1604
1605 // Переподключаем обработчики после обновления HTML
1606 const newQuerySelector = panel.querySelector('#query-selector-panel');
1607 newQuerySelector.value = queryIndex;
1608
1609 // Переподключаем обработчик изменения запроса
1610 newQuerySelector.addEventListener('change', async () => {
1611 const newSelectedIndex = parseInt(newQuerySelector.value);
1612 await updatePanelForQuery(newSelectedIndex);
1613 });
1614
1615 // Переподключаем обработчик закрытия
1616 const newCloseBtn = panel.querySelector('.ozon-parser-details-panel-close');
1617 newCloseBtn.addEventListener('click', () => {
1618 panel.classList.remove('open');
1619 setTimeout(() => panel.remove(), 300);
1620 });
1621
1622 // Отображаем детали выбранного запроса
1623 displayQueryDetails(queryIndex);
1624 }
1625
1626 // Отображаем детали первого запроса
1627 displayQueryDetails(0);
1628
1629 // Обработчик изменения запроса
1630 const querySelector = panel.querySelector('#query-selector-panel');
1631 querySelector.addEventListener('change', async () => {
1632 const selectedIndex = parseInt(querySelector.value);
1633 await updatePanelForQuery(selectedIndex);
1634 });
1635
1636 // Обработчик закрытия панели
1637 const closeBtn = panel.querySelector('.ozon-parser-details-panel-close');
1638 closeBtn.addEventListener('click', () => {
1639 panel.classList.remove('open');
1640 setTimeout(() => panel.remove(), 300);
1641 });
1642
1643 // Открываем панель с анимацией
1644 setTimeout(() => {
1645 panel.classList.add('open');
1646 }, 10);
1647
1648 console.log('Ozon Product Parser: Product details panel opened');
1649 }
1650
1651 // Создаем UI кнопок
1652 function createUI() {
1653 const container = document.createElement('div');
1654 container.className = 'ozon-parser-container';
1655
1656 // Загружаем сохраненную позицию
1657 GM.getValue('ozon_parser_position', JSON.stringify({ top: 20, right: 20 })).then(posJson => {
1658 const pos = JSON.parse(posJson);
1659 container.style.top = pos.top + 'px';
1660 container.style.right = pos.right + 'px';
1661 });
1662
1663 // Загружаем состояние сворачивания
1664 GM.getValue('ozon_parser_collapsed', 'false').then(collapsed => {
1665 if (collapsed === 'true') {
1666 container.classList.add('collapsed');
1667 buttonsWrapper.classList.add('hidden');
1668 }
1669 });
1670
1671 // Кнопка сворачивания/разворачивания
1672 const toggleBtn = document.createElement('button');
1673 toggleBtn.className = 'ozon-parser-toggle-btn';
1674 toggleBtn.innerHTML = '📊';
1675 toggleBtn.title = 'Свернуть/Развернуть панель';
1676
1677 // Обертка для кнопок
1678 const buttonsWrapper = document.createElement('div');
1679 buttonsWrapper.className = 'ozon-parser-buttons-wrapper';
1680
1681 const parseBtn = document.createElement('button');
1682 parseBtn.className = 'ozon-parser-btn';
1683 parseBtn.textContent = 'Парсинг';
1684 parseBtn.addEventListener('click', (e) => {
1685 e.stopPropagation();
1686 showParseModal();
1687 });
1688
1689 const pricesBtn = document.createElement('button');
1690 pricesBtn.className = 'ozon-parser-btn';
1691 pricesBtn.id = 'prices-btn';
1692 pricesBtn.style.background = 'linear-gradient(135deg, #dc3545 0%, #c82333 100%)';
1693 pricesBtn.style.boxShadow = '0 4px 12px rgba(220, 53, 69, 0.3)';
1694 pricesBtn.textContent = 'Цены';
1695 pricesBtn.addEventListener('click', (e) => {
1696 e.stopPropagation();
1697 showPricesModal();
1698 });
1699
1700 const resultsBtn = document.createElement('button');
1701 resultsBtn.className = 'ozon-parser-btn';
1702 resultsBtn.id = 'results-btn';
1703 resultsBtn.style.background = 'linear-gradient(135deg, #28a745 0%, #1e7e34 100%)';
1704 resultsBtn.style.boxShadow = '0 4px 12px rgba(40, 167, 69, 0.3)';
1705 resultsBtn.textContent = 'Результаты';
1706 resultsBtn.addEventListener('click', (e) => {
1707 e.stopPropagation();
1708 showResultsModal();
1709 });
1710
1711 buttonsWrapper.appendChild(parseBtn);
1712 buttonsWrapper.appendChild(pricesBtn);
1713 buttonsWrapper.appendChild(resultsBtn);
1714
1715 container.appendChild(toggleBtn);
1716 container.appendChild(buttonsWrapper);
1717
1718 // Обработчик сворачивания/разворачивания
1719 toggleBtn.addEventListener('click', async (e) => {
1720 e.stopPropagation();
1721 const isCollapsed = container.classList.toggle('collapsed');
1722 buttonsWrapper.classList.toggle('hidden');
1723 await GM.setValue('ozon_parser_collapsed', isCollapsed ? 'true' : 'false');
1724 });
1725
1726 // Перетаскивание панели
1727 let isDragging = false;
1728 let currentX;
1729 let currentY;
1730 let initialX;
1731 let initialY;
1732
1733 container.addEventListener('mousedown', (e) => {
1734 // Не начинаем перетаскивание, если кликнули на кнопку
1735 if (e.target.tagName === 'BUTTON') return;
1736
1737 isDragging = true;
1738 initialX = e.clientX - container.offsetLeft;
1739 initialY = e.clientY - container.offsetTop;
1740 container.style.cursor = 'grabbing';
1741 });
1742
1743 document.addEventListener('mousemove', (e) => {
1744 if (!isDragging) return;
1745
1746 e.preventDefault();
1747 currentX = e.clientX - initialX;
1748 currentY = e.clientY - initialY;
1749
1750 // Ограничиваем перемещение в пределах окна
1751 const maxX = window.innerWidth - container.offsetWidth;
1752 const maxY = window.innerHeight - container.offsetHeight;
1753
1754 currentX = Math.max(0, Math.min(currentX, maxX));
1755 currentY = Math.max(0, Math.min(currentY, maxY));
1756
1757 container.style.left = currentX + 'px';
1758 container.style.top = currentY + 'px';
1759 container.style.right = 'auto';
1760 });
1761
1762 document.addEventListener('mouseup', async () => {
1763 if (isDragging) {
1764 isDragging = false;
1765 container.style.cursor = 'move';
1766
1767 // Сохраняем позицию
1768 const rect = container.getBoundingClientRect();
1769 await GM.setValue('ozon_parser_position', JSON.stringify({
1770 top: rect.top,
1771 right: window.innerWidth - rect.right
1772 }));
1773 }
1774 });
1775
1776 document.body.appendChild(container);
1777 console.log('Ozon Product Parser: UI created');
1778 }
1779
1780 // Показываем модальное окно для ввода запросов
1781 function showParseModal() {
1782 const modal = document.createElement('div');
1783 modal.className = 'ozon-parser-modal';
1784
1785 const content = document.createElement('div');
1786 content.className = 'ozon-parser-modal-content';
1787 content.innerHTML = `
1788 <div class="ozon-parser-modal-header">Парсинг товаров Ozon</div>
1789 <div class="ozon-parser-modal-body">
1790 <div class="ozon-parser-info">
1791 Введите поисковые запросы (каждый с новой строки). Парсер извлечет топ-16 товаров для каждого запроса.
1792 </div>
1793 <div style="margin-bottom: 15px;">
1794 <label style="font-size: 14px; font-weight: 600; margin-bottom: 8px; display: block;">Скидка Ozon (%):</label>
1795 <div style="display: flex; gap: 10px; align-items: center;">
1796 <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;">
1797 <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>
1798 </div>
1799 <div style="font-size: 12px; color: #666; margin-top: 5px;">
1800 Укажите среднюю скидку Ozon для расчета базовой цены (цены поручения). Например, если товар продается по 500₽ со скидкой 50%, то базовая цена = 1000₽.
1801 </div>
1802 </div>
1803 <div style="margin-bottom: 15px;">
1804 <button class="ozon-parser-btn" id="load-list-btn" style="width: 100%; margin-bottom: 10px;">Загрузить сохраненный список</button>
1805 </div>
1806 <textarea class="ozon-parser-textarea" placeholder="Например: гинкго билоба аргинин витамин д"></textarea>
1807 <div style="margin-top: 15px; display: flex; gap: 10px; align-items: center;">
1808 <input type="text" class="ozon-parser-search" id="list-name-input" placeholder="Название списка (например: БАДы март 2024)" style="flex: 1; margin-bottom: 0;">
1809 <button class="ozon-parser-btn secondary" id="save-list-btn">Сохранить список</button>
1810 </div>
1811 <div style="margin-top: 15px;">
1812 <button class="ozon-parser-btn secondary" id="manage-costs-btn" style="width: 100%;">Управление расходами (себестоимость, комиссия, доставка)</button>
1813 </div>
1814 <div class="ozon-parser-progress" style="display: none;">
1815 <div class="ozon-parser-progress-text">Обработка запросов...</div>
1816 <div class="ozon-parser-progress-bar">
1817 <div class="ozon-parser-progress-fill" style="width: 0%"></div>
1818 </div>
1819 </div>
1820 </div>
1821 <div class="ozon-parser-modal-footer">
1822 <button class="ozon-parser-btn" id="cancel-parse-btn">Отмена</button>
1823 <button class="ozon-parser-btn" id="start-parsing-btn">Начать парсинг</button>
1824 </div>
1825 `;
1826
1827 modal.appendChild(content);
1828 document.body.appendChild(modal);
1829
1830 // Закрытие по клику на фон
1831 modal.addEventListener('click', (e) => {
1832 if (e.target === modal) {
1833 modal.remove();
1834 }
1835 });
1836
1837 // Обработчик кнопки отмены
1838 const cancelBtn = content.querySelector('#cancel-parse-btn');
1839 cancelBtn.addEventListener('click', () => {
1840 modal.remove();
1841 });
1842
1843 // Обработчик кнопки расчета скидки
1844 const calculateDiscountBtn = content.querySelector('#calculate-discount-btn');
1845 calculateDiscountBtn.addEventListener('click', async () => {
1846 calculateDiscountBtn.disabled = true;
1847 calculateDiscountBtn.textContent = 'Расчет...';
1848
1849 try {
1850 // Сохраняем флаг для автоматического расчета
1851 await GM.setValue('ozon_parser_calculate_discount', 'true');
1852
1853 // Открываем страницу в новой вкладке
1854 await GM.openInTab('https://seller.ozon.ru/app/prices/control', false);
1855
1856 // Ждем результата расчета
1857 let attempts = 0;
1858 const maxAttempts = 60; // 60 секунд максимум
1859
1860 const checkInterval = setInterval(async () => {
1861 attempts++;
1862 const calculatedDiscount = await GM.getValue('ozon_parser_calculated_discount', null);
1863 const calculateFlag = await GM.getValue('ozon_parser_calculate_discount', 'false');
1864
1865 if (calculatedDiscount !== null && calculateFlag === 'false') {
1866 // Расчет завершен
1867 clearInterval(checkInterval);
1868
1869 // Вставляем значение в поле
1870 const discountInput = content.querySelector('#ozon-discount-input');
1871 discountInput.value = parseFloat(calculatedDiscount).toFixed(1);
1872
1873 // Очищаем временное значение
1874 await GM.deleteValue('ozon_parser_calculated_discount');
1875
1876 calculateDiscountBtn.disabled = false;
1877 calculateDiscountBtn.textContent = 'Рассчитать автоматически';
1878
1879 alert(`Скидка Ozon успешно рассчитана: ${parseFloat(calculatedDiscount).toFixed(1)}%`);
1880 } else if (attempts >= maxAttempts) {
1881 // Таймаут
1882 clearInterval(checkInterval);
1883 calculateDiscountBtn.disabled = false;
1884 calculateDiscountBtn.textContent = 'Рассчитать автоматически';
1885 alert('Не удалось рассчитать скидку. Попробуйте еще раз.');
1886 }
1887 }, 1000);
1888 } catch (error) {
1889 console.error('Ozon Product Parser: Error calculating discount:', error);
1890 calculateDiscountBtn.disabled = false;
1891 calculateDiscountBtn.textContent = 'Рассчитать автоматически';
1892 alert('Ошибка при расчете скидки: ' + error.message);
1893 }
1894 });
1895
1896 // Обработчик кнопки сохранения списка
1897 const saveListBtn = content.querySelector('#save-list-btn');
1898 saveListBtn.addEventListener('click', async () => {
1899 const textarea = content.querySelector('.ozon-parser-textarea');
1900 const listNameInput = content.querySelector('#list-name-input');
1901 const queries = textarea.value.split('\n').filter(q => q.trim());
1902 const listName = listNameInput.value.trim();
1903
1904 if (queries.length === 0) {
1905 alert('Пожалуйста, введите хотя бы один запрос');
1906 return;
1907 }
1908
1909 if (!listName) {
1910 alert('Пожалуйста, введите название списка');
1911 return;
1912 }
1913
1914 // Сохраняем список
1915 const savedListsJson = await GM.getValue('ozon_parser_saved_lists', '{}');
1916 const savedLists = JSON.parse(savedListsJson);
1917
1918 savedLists[listName] = {
1919 queries: queries,
1920 createdAt: new Date().toISOString(),
1921 updatedAt: new Date().toISOString()
1922 };
1923
1924 await GM.setValue('ozon_parser_saved_lists', JSON.stringify(savedLists));
1925
1926 alert(`Список "${listName}" успешно сохранен!`);
1927 console.log(`Ozon Product Parser: List "${listName}" saved with ${queries.length} queries`);
1928 });
1929
1930 // Обработчик кнопки загрузки списка
1931 const loadListBtn = content.querySelector('#load-list-btn');
1932 loadListBtn.addEventListener('click', async () => {
1933 await showLoadListModal(content);
1934 });
1935
1936 // Обработчик кнопки управления расходами
1937 const manageCostsBtn = content.querySelector('#manage-costs-btn');
1938 manageCostsBtn.addEventListener('click', async () => {
1939 await showManageCostsModal();
1940 });
1941
1942 // Обработчик кнопки парсинга
1943 const startBtn = content.querySelector('#start-parsing-btn');
1944 startBtn.addEventListener('click', async () => {
1945 const textarea = content.querySelector('.ozon-parser-textarea');
1946 const listNameInput = content.querySelector('#list-name-input');
1947 const ozonDiscountInput = content.querySelector('#ozon-discount-input');
1948 const queries = textarea.value.split('\n').filter(q => q.trim());
1949 const listName = listNameInput.value.trim() || 'Без названия';
1950 const ozonDiscount = parseFloat(ozonDiscountInput.value) || 50;
1951
1952 if (queries.length === 0) {
1953 alert('Пожалуйста, введите хотя бы один запрос');
1954 return;
1955 }
1956
1957 if (ozonDiscount < 0 || ozonDiscount > 100) {
1958 alert('Скидка Ozon должна быть от 0 до 100%');
1959 return;
1960 }
1961
1962 // Сохраняем скидку Ozon
1963 await GM.setValue('ozon_parser_discount', ozonDiscount);
1964
1965 startBtn.disabled = true;
1966 startBtn.textContent = 'Парсинг...';
1967 await startParsing(queries, listName, content);
1968 });
1969
1970 console.log('Ozon Product Parser: Parse modal shown');
1971 }
1972
1973 // Показываем модальное окно для загрузки сохраненного списка
1974 async function showLoadListModal(parentContent) {
1975 const savedListsJson = await GM.getValue('ozon_parser_saved_lists', '{}');
1976 const savedLists = JSON.parse(savedListsJson);
1977 const listNames = Object.keys(savedLists);
1978
1979 if (listNames.length === 0) {
1980 alert('Нет сохраненных списков');
1981 return;
1982 }
1983
1984 const loadModal = document.createElement('div');
1985 loadModal.className = 'ozon-parser-modal';
1986 loadModal.style.zIndex = '10002';
1987
1988 const loadContent = document.createElement('div');
1989 loadContent.className = 'ozon-parser-modal-content';
1990 loadContent.style.maxWidth = '600px';
1991
1992 let listsHTML = '<div class="ozon-parser-modal-header">Выберите список</div><div class="ozon-parser-modal-body">';
1993
1994 listNames.forEach(listName => {
1995 const list = savedLists[listName];
1996 const date = new Date(list.createdAt).toLocaleDateString('ru-RU');
1997 const queriesCount = list.queries.length;
1998 listsHTML += `
1999 <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;"
2000 class="saved-list-item" data-list-name="${listName}">
2001 <div style="flex: 1; cursor: pointer;" class="saved-list-info">
2002 <div style="font-weight: 600; font-size: 16px; margin-bottom: 5px;">${listName}</div>
2003 <div style="font-size: 13px; color: #666;">Запросов: ${queriesCount} | Создан: ${date}</div>
2004 </div>
2005 <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>
2006 </div>
2007 `;
2008 });
2009
2010 listsHTML += '</div><div class="ozon-parser-modal-footer"><button class="ozon-parser-btn" id="close-load-modal">Отмена</button></div>';
2011
2012 loadContent.innerHTML = listsHTML;
2013 loadModal.appendChild(loadContent);
2014 document.body.appendChild(loadModal);
2015
2016 // Обработчик выбора списка (только для info блока)
2017 loadContent.querySelectorAll('.saved-list-info').forEach(info => {
2018 info.addEventListener('click', () => {
2019 const listItem = info.closest('.saved-list-item');
2020 const listName = listItem.getAttribute('data-list-name');
2021 const list = savedLists[listName];
2022
2023 // Заполняем textarea
2024 const textarea = parentContent.querySelector('.ozon-parser-textarea');
2025 const listNameInput = parentContent.querySelector('#list-name-input');
2026 textarea.value = list.queries.join('\n');
2027 listNameInput.value = listName;
2028
2029 loadModal.remove();
2030 });
2031
2032 // Hover эффект
2033 const listItem = info.closest('.saved-list-item');
2034 info.addEventListener('mouseenter', () => {
2035 listItem.style.borderColor = '#005bff';
2036 listItem.style.background = '#f8f9fa';
2037 });
2038 info.addEventListener('mouseleave', () => {
2039 listItem.style.borderColor = '#e0e0e0';
2040 listItem.style.background = 'white';
2041 });
2042 });
2043
2044 // Обработчик удаления списка
2045 loadContent.querySelectorAll('[data-delete-list]').forEach(deleteBtn => {
2046 deleteBtn.addEventListener('click', async (e) => {
2047 e.stopPropagation();
2048 const listName = deleteBtn.getAttribute('data-delete-list');
2049
2050 if (!confirm(`Вы уверены, что хотите удалить список "${listName}"?`)) {
2051 return;
2052 }
2053
2054 // Удаляем список из savedLists
2055 delete savedLists[listName];
2056 await GM.setValue('ozon_parser_saved_lists', JSON.stringify(savedLists));
2057
2058 // Также удаляем результаты парсинга для этого списка
2059 const listResultsJson = await GM.getValue('ozon_parser_list_results', '{}');
2060 const listResults = JSON.parse(listResultsJson);
2061 delete listResults[listName];
2062 await GM.setValue('ozon_parser_list_results', JSON.stringify(listResults));
2063
2064 console.log(`Ozon Product Parser: List "${listName}" deleted from load modal`);
2065
2066 // Удаляем элемент из DOM
2067 const listItem = deleteBtn.closest('.saved-list-item');
2068 listItem.remove();
2069
2070 // Если списков не осталось, закрываем модальное окно
2071 const remainingLists = loadContent.querySelectorAll('.saved-list-item');
2072 if (remainingLists.length === 0) {
2073 alert('Все списки удалены');
2074 loadModal.remove();
2075 }
2076 });
2077 });
2078
2079 // Закрытие
2080 loadContent.querySelector('#close-load-modal').addEventListener('click', () => {
2081 loadModal.remove();
2082 });
2083
2084 loadModal.addEventListener('click', (e) => {
2085 if (e.target === loadModal) {
2086 loadModal.remove();
2087 }
2088 });
2089 }
2090
2091 // Показываем модальное окно управления расходами
2092 async function showManageCostsModal() {
2093 const costsJson = await GM.getValue('ozon_parser_costs', '{}');
2094 const costs = JSON.parse(costsJson);
2095
2096 const modal = document.createElement('div');
2097 modal.className = 'ozon-parser-modal';
2098 modal.style.zIndex = '10002';
2099
2100 const content = document.createElement('div');
2101 content.className = 'ozon-parser-modal-content';
2102 content.style.maxWidth = '800px';
2103
2104 content.innerHTML = `
2105 <div class="ozon-parser-modal-header">Управление расходами</div>
2106 <div class="ozon-parser-modal-body">
2107 <div class="ozon-parser-info">
2108 Укажите расходы для ваших товаров (SKU). Эти данные будут использоваться для расчета прибыли и оптимальной цены.
2109 </div>
2110
2111 <!-- Блок загрузки файла -->
2112 <div style="margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 8px;">
2113 <label style="font-size: 14px; font-weight: 600; margin-bottom: 8px; display: block;">Загрузить данные из файла:</label>
2114 <div style="display: flex; gap: 10px; align-items: center; margin-bottom: 10px;">
2115 <input type="file" id="cost-file-input" accept=".csv,.json,.txt" style="flex: 1; padding: 8px; border: 2px solid #e0e0e0; border-radius: 8px;">
2116 <button class="ozon-parser-btn" id="upload-costs-btn">Загрузить</button>
2117 </div>
2118 <details style="margin-top: 10px;">
2119 <summary style="cursor: pointer; font-size: 12px; color: #666;">Формат файлов</summary>
2120 <div style="font-size: 12px; color: #666; margin-top: 8px; line-height: 1.6;">
2121 <strong>CSV/TXT:</strong> SKU,Себестоимость,Комиссия,Доставка<br>
2122 Пример: 320244429,158.4,50,90<br><br>
2123 <strong>JSON:</strong> {"SKU": {"cost": 158.4, "commission": 0.5, "delivery": 90}}<br>
2124 Примечание: Комиссия в CSV указывается в процентах (50), в JSON - как десятичная дробь (0.5)
2125 </div>
2126 </details>
2127 </div>
2128
2129 <div style="margin-bottom: 15px;">
2130 <label style="font-size: 14px; font-weight: 600; margin-bottom: 8px; display: block;">Добавить новый товар:</label>
2131 <div style="display: grid; grid-template-columns: 2fr 1fr 1fr 1fr auto; gap: 10px; align-items: end;">
2132 <div>
2133 <label style="font-size: 12px; color: #666;">SKU</label>
2134 <input type="text" class="ozon-parser-search" id="new-sku" placeholder="320244429" style="margin-bottom: 0;">
2135 </div>
2136 <div>
2137 <label style="font-size: 12px; color: #666;">Себестоимость (₽)</label>
2138 <input type="number" class="ozon-parser-search" id="new-cost" placeholder="158.4" step="0.01" style="margin-bottom: 0;">
2139 </div>
2140 <div>
2141 <label style="font-size: 12px; color: #666;">Комиссия (%)</label>
2142 <input type="number" class="ozon-parser-search" id="new-commission" placeholder="50" step="0.1" style="margin-bottom: 0;">
2143 </div>
2144 <div>
2145 <label style="font-size: 12px; color: #666;">Доставка (₽)</label>
2146 <input type="number" class="ozon-parser-search" id="new-delivery" placeholder="90" step="0.01" style="margin-bottom: 0;">
2147 </div>
2148 <button class="ozon-parser-btn" id="add-cost-btn" style="margin-bottom: 0;">Добавить</button>
2149 </div>
2150 </div>
2151 <div id="costs-list" style="max-height: 400px; overflow-y: auto;">
2152 ${Object.keys(costs).length === 0 ? '<p style="text-align: center; color: #999;">Нет добавленных товаров</p>' : ''}
2153 </div>
2154 </div>
2155 <div class="ozon-parser-modal-footer">
2156 <button class="ozon-parser-btn" id="close-costs-modal">Закрыть</button>
2157 </div>
2158 `;
2159
2160 modal.appendChild(content);
2161 document.body.appendChild(modal);
2162
2163 // Функция для парсинга CSV
2164 function parseCSV(text) {
2165 const lines = text.trim().split('\n');
2166 const result = {};
2167
2168 for (let i = 0; i < lines.length; i++) {
2169 const line = lines[i].trim();
2170 if (!line || line.startsWith('SKU')) continue; // Пропускаем заголовок
2171
2172 const parts = line.split(',').map(p => p.trim());
2173 if (parts.length < 4) continue;
2174
2175 // Удаляем кавычки из SKU, если они есть
2176 let sku = parts[0].replace(/^["']|["']$/g, '');
2177 const cost = parseFloat(parts[1]);
2178 const commission = parseFloat(parts[2]);
2179 const delivery = parseFloat(parts[3]);
2180
2181 if (sku && !isNaN(cost) && !isNaN(commission) && !isNaN(delivery)) {
2182 result[sku] = {
2183 cost: cost,
2184 commission: commission / 100, // Конвертируем проценты в десятичную дробь
2185 delivery: delivery
2186 };
2187 }
2188 }
2189
2190 return result;
2191 }
2192
2193 // Функция для парсинга JS-объекта из txt файла
2194 function parseJSObject(text) {
2195 try {
2196 // Удаляем возможные комментарии и лишние пробелы
2197 let cleanText = text.trim();
2198
2199 // Если текст не начинается с {, добавляем открывающую скобку
2200 if (!cleanText.startsWith('{')) {
2201 cleanText = '{' + cleanText;
2202 }
2203
2204 // Если текст не заканчивается на }, добавляем закрывающую скобку
2205 if (!cleanText.endsWith('}')) {
2206 // Удаляем последнюю запятую, если она есть
2207 cleanText = cleanText.replace(/,\s*$/, '');
2208 cleanText = cleanText + '}';
2209 }
2210
2211 console.log('Ozon Product Parser: Attempting to parse JS object, length:', cleanText.length);
2212
2213 // Используем eval для парсинга JS объекта (безопасно, т.к. это локальный файл пользователя)
2214 const result = eval('(' + cleanText + ')');
2215
2216 // Валидация формата
2217 if (typeof result !== 'object' || result === null) {
2218 throw new Error('Неверный формат: ожидается объект');
2219 }
2220
2221 // Проверяем структуру данных
2222 for (const sku in result) {
2223 const item = result[sku];
2224 if (typeof item !== 'object' || item === null) {
2225 throw new Error(`Неверный формат данных для SKU ${sku}. Ожидается объект с полями cost, commission, delivery`);
2226 }
2227 if (typeof item.cost !== 'number' || typeof item.commission !== 'number' || typeof item.delivery !== 'number') {
2228 throw new Error(`Неверный формат данных для SKU ${sku}. Ожидается: { cost: число, commission: число (0-1), delivery: число }. Получено: ${JSON.stringify(item)}`);
2229 }
2230 }
2231
2232 console.log('Ozon Product Parser: Successfully parsed JS object with', Object.keys(result).length, 'items');
2233 return result;
2234 } catch (error) {
2235 console.error('Ozon Product Parser: JS object parse error:', error);
2236 throw new Error(`Ошибка парсинга JS-объекта: ${error.message}`);
2237 }
2238 }
2239
2240 // Обработчик загрузки файла
2241 const uploadBtn = content.querySelector('#upload-costs-btn');
2242 uploadBtn.addEventListener('click', async () => {
2243 const fileInput = content.querySelector('#cost-file-input');
2244 const file = fileInput.files[0];
2245
2246 if (!file) {
2247 alert('Пожалуйста, выберите файл');
2248 return;
2249 }
2250
2251 try {
2252 const text = await file.text();
2253 let newCosts = {};
2254
2255 console.log('Ozon Product Parser: Processing file:', file.name, 'Size:', file.size, 'bytes');
2256
2257 if (file.name.endsWith('.json')) {
2258 // Парсим JSON
2259 try {
2260 newCosts = JSON.parse(text);
2261 console.log('Ozon Product Parser: Parsed JSON successfully');
2262 } catch (jsonError) {
2263 console.error('Ozon Product Parser: JSON parse error:', jsonError);
2264 throw new Error(`Ошибка парсинга JSON: ${jsonError.message}. Проверьте, что файл содержит корректный JSON формат.`);
2265 }
2266
2267 // Валидация JSON формата
2268 for (const sku in newCosts) {
2269 const item = newCosts[sku];
2270 if (typeof item !== 'object' || item === null) {
2271 throw new Error(`Неверный формат данных для SKU ${sku}. Ожидается объект с полями cost, commission, delivery`);
2272 }
2273 if (typeof item.cost !== 'number' || typeof item.commission !== 'number' || typeof item.delivery !== 'number') {
2274 throw new Error(`Неверный формат данных для SKU ${sku}. Ожидается: {"cost": число, "commission": число, "delivery": число}. Получено: ${JSON.stringify(item)}`);
2275 }
2276 }
2277 } else if (file.name.endsWith('.txt')) {
2278 // Пытаемся определить формат: JS-объект или CSV
2279 const trimmedText = text.trim();
2280 console.log('Ozon Product Parser: Processing TXT file, first 100 chars:', trimmedText.substring(0, 100));
2281
2282 if (trimmedText.startsWith('{') || trimmedText.includes('{')) {
2283 // JS-объект формат
2284 console.log('Ozon Product Parser: Detected JS object format');
2285 newCosts = parseJSObject(text);
2286 } else {
2287 // CSV формат
2288 console.log('Ozon Product Parser: Detected CSV format');
2289 newCosts = parseCSV(text);
2290 }
2291 } else {
2292 // CSV формат для других расширений
2293 console.log('Ozon Product Parser: Processing as CSV format');
2294 newCosts = parseCSV(text);
2295 }
2296
2297 console.log('Ozon Product Parser: Extracted', Object.keys(newCosts).length, 'items from file');
2298
2299 if (Object.keys(newCosts).length === 0) {
2300 alert('Не удалось извлечь данные из файла. Проверьте формат.\n\nОжидаемые форматы:\n\nCSV: SKU,Себестоимость,Комиссия,Доставка\nПример: 320244429,158.4,50,90\n\nJSON: {"SKU": {"cost": 158.4, "commission": 0.5, "delivery": 90}}');
2301 return;
2302 }
2303
2304 // Объединяем с существующими данными
2305 const mergedCosts = { ...costs, ...newCosts };
2306 await GM.setValue('ozon_parser_costs', JSON.stringify(mergedCosts));
2307
2308 // Обновляем costs переменную
2309 Object.assign(costs, newCosts);
2310
2311 alert(`Успешно загружено ${Object.keys(newCosts).length} товаров`);
2312 console.log('Ozon Product Parser: Successfully saved costs data');
2313 displayCostsList();
2314
2315 // Очищаем input
2316 fileInput.value = '';
2317 } catch (error) {
2318 console.error('Ozon Product Parser: Error uploading costs file:', error);
2319 console.error('Ozon Product Parser: Error stack:', error.stack);
2320 console.error('Ozon Product Parser: Error name:', error.name);
2321 console.error('Ozon Product Parser: Error message:', error.message);
2322 alert('Ошибка при загрузке файла:\n\n' + error.message + '\n\nПроверьте формат файла и попробуйте снова.');
2323 }
2324 });
2325
2326 // Функция для отображения списка расходов
2327 function displayCostsList() {
2328 const costsList = content.querySelector('#costs-list');
2329
2330 if (Object.keys(costs).length === 0) {
2331 costsList.innerHTML = '<p style="text-align: center; color: #999;">Нет добавленных товаров</p>';
2332 return;
2333 }
2334
2335 let html = '<div style="margin-bottom: 15px; display: flex; justify-content: space-between; align-items: center;">';
2336 html += '<div style="font-size: 14px; font-weight: 600;">Всего товаров: ' + Object.keys(costs).length + '</div>';
2337 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>';
2338 html += '</div>';
2339 html += '<table class="ozon-parser-results-table"><thead><tr><th>SKU</th><th>Себестоимость</th><th>Комиссия</th><th>Доставка</th><th>Действия</th></tr></thead><tbody>';
2340
2341 Object.keys(costs).forEach(sku => {
2342 const cost = costs[sku];
2343 html += `
2344 <tr>
2345 <td><a href="https://www.ozon.ru/product/${sku}" target="_blank" class="ozon-parser-sku-link">${sku}</a></td>
2346 <td>${cost.cost.toFixed(2)} ₽</td>
2347 <td>${(cost.commission * 100).toFixed(2)}%</td>
2348 <td>${cost.delivery.toFixed(2)} ₽</td>
2349 <td>
2350 <button class="ozon-parser-btn" data-edit-sku="${sku}" style="padding: 6px 12px; font-size: 12px; margin-right: 5px;">Изменить</button>
2351 <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>
2352 </td>
2353 </tr>
2354 `;
2355 });
2356
2357 html += '</tbody></table>';
2358 costsList.innerHTML = html;
2359
2360 // Обработчик для кнопки удаления всех расходов
2361 const deleteAllBtn = costsList.querySelector('#delete-all-costs-btn');
2362 if (deleteAllBtn) {
2363 deleteAllBtn.addEventListener('click', async () => {
2364 if (confirm('Вы уверены, что хотите удалить ВСЕ данные о расходах? Это действие нельзя отменить.')) {
2365 // Очищаем все данные
2366 Object.keys(costs).forEach(key => delete costs[key]);
2367 await GM.setValue('ozon_parser_costs', JSON.stringify({}));
2368 displayCostsList();
2369 alert('Все данные о расходах удалены');
2370 }
2371 });
2372 }
2373
2374 // Обработчики для кнопок удаления
2375 costsList.querySelectorAll('[data-delete-sku]').forEach(btn => {
2376 btn.addEventListener('click', async () => {
2377 const sku = btn.getAttribute('data-delete-sku');
2378 if (confirm(`Удалить данные о расходах для SKU ${sku}?`)) {
2379 delete costs[sku];
2380 await GM.setValue('ozon_parser_costs', JSON.stringify(costs));
2381 displayCostsList();
2382 }
2383 });
2384 });
2385
2386 // Обработчики для кнопок изменения
2387 costsList.querySelectorAll('[data-edit-sku]').forEach(btn => {
2388 btn.addEventListener('click', () => {
2389 const sku = btn.getAttribute('data-edit-sku');
2390 const cost = costs[sku];
2391
2392 content.querySelector('#new-sku').value = sku;
2393 content.querySelector('#new-cost').value = cost.cost;
2394 content.querySelector('#new-commission').value = cost.commission * 100;
2395 content.querySelector('#new-delivery').value = cost.delivery;
2396
2397 content.querySelector('#new-sku').scrollIntoView({ behavior: 'smooth', block: 'center' });
2398 });
2399 });
2400 }
2401
2402 displayCostsList();
2403
2404 // Обработчик добавления нового товара
2405 const addBtn = content.querySelector('#add-cost-btn');
2406 addBtn.addEventListener('click', async () => {
2407 const sku = content.querySelector('#new-sku').value.trim();
2408 const cost = parseFloat(content.querySelector('#new-cost').value);
2409 const commission = parseFloat(content.querySelector('#new-commission').value);
2410 const delivery = parseFloat(content.querySelector('#new-delivery').value);
2411
2412 if (!sku) {
2413 alert('Пожалуйста, введите SKU');
2414 return;
2415 }
2416
2417 if (isNaN(cost) || cost < 0) {
2418 alert('Пожалуйста, введите корректную себестоимость');
2419 return;
2420 }
2421
2422 if (isNaN(commission) || commission < 0 || commission > 100) {
2423 alert('Пожалуйста, введите корректную комиссию (0-100%)');
2424 return;
2425 }
2426
2427 if (isNaN(delivery) || delivery < 0) {
2428 alert('Пожалуйста, введите корректную стоимость доставки');
2429 return;
2430 }
2431
2432 costs[sku] = {
2433 cost: cost,
2434 commission: commission / 100, // Сохраняем как десятичную дробь
2435 delivery: delivery
2436 };
2437
2438 await GM.setValue('ozon_parser_costs', JSON.stringify(costs));
2439
2440 // Очищаем поля
2441 content.querySelector('#new-sku').value = '';
2442 content.querySelector('#new-cost').value = '';
2443 content.querySelector('#new-commission').value = '';
2444 content.querySelector('#new-delivery').value = '';
2445
2446 displayCostsList();
2447 });
2448
2449 // Закрытие
2450 content.querySelector('#close-costs-modal').addEventListener('click', () => {
2451 modal.remove();
2452 });
2453
2454 modal.addEventListener('click', (e) => {
2455 if (e.target === modal) {
2456 modal.remove();
2457 }
2458 });
2459 }
2460
2461 // Извлекаем скидку со страницы управления ценами
2462 async function extractDiscountFromPage() {
2463 console.log('Ozon Product Parser: Extracting discount from prices page');
2464
2465 // Ждем загрузки таблицы с ценами
2466 await waitForPricesTable();
2467
2468 // Прокручиваем страницу вниз для загрузки цен (ленивая загрузка)
2469 console.log('Ozon Product Parser: Scrolling to load prices');
2470 for (let i = 0; i < 3; i++) {
2471 window.scrollBy(0, 500);
2472 await new Promise(resolve => setTimeout(resolve, 2000));
2473 }
2474
2475 try {
2476 // Ищем цены по правильным классам
2477 // Цена продажи (с картой Ozon): index_priceByOzonCardCurrency_3DLKf
2478 // Базовая цена (цена поручения): index_priceAmount_3dfpL
2479
2480 const salePriceElements = document.querySelectorAll('.index_priceByOzonCardCurrency_3DLKf');
2481 const basePriceElements = document.querySelectorAll('.index_priceAmount_3dfpL');
2482
2483 console.log(`Ozon Product Parser: Found ${salePriceElements.length} sale prices and ${basePriceElements.length} base prices`);
2484
2485 if (salePriceElements.length === 0 || basePriceElements.length === 0) {
2486 console.error('Ozon Product Parser: Could not find price elements on the page');
2487 alert('Не удалось найти цены на странице. Убедитесь, что у вас есть товары с ценами.');
2488 await GM.setValue('ozon_parser_calculate_discount', 'false');
2489 return;
2490 }
2491
2492 // Собираем пары цен (базовая и продажная)
2493 let validDiscounts = [];
2494
2495 // Ищем строки таблицы с обеими ценами
2496 const rows = document.querySelectorAll('tr');
2497 for (const row of rows) {
2498 const salePrice = row.querySelector('.index_priceByOzonCardCurrency_3DLKf');
2499 const basePrice = row.querySelector('.index_priceAmount_3dfpL');
2500
2501 if (salePrice && basePrice) {
2502 const salePriceText = salePrice.textContent.trim();
2503 const basePriceText = basePrice.textContent.trim();
2504
2505 // Парсим цены (убираем пробелы и символ рубля)
2506 const salePriceValue = parseFloat(salePriceText.replace(/\s/g, '').replace('₽', '').replace(',', '.'));
2507 const basePriceValue = parseFloat(basePriceText.replace(/\s/g, '').replace('₽', '').replace(',', '.'));
2508
2509 if (!isNaN(salePriceValue) && !isNaN(basePriceValue) && basePriceValue > 0 && salePriceValue > 0) {
2510 // Рассчитываем скидку: (базовая цена - цена продажи) / базовая цена * 100
2511 const discountAmount = basePriceValue - salePriceValue;
2512 const discountPercent = (discountAmount / basePriceValue) * 100;
2513
2514 if (discountPercent > 0 && discountPercent < 100) {
2515 validDiscounts.push({
2516 basePrice: basePriceValue,
2517 salePrice: salePriceValue,
2518 discount: discountPercent
2519 });
2520
2521 console.log(`Ozon Product Parser: Base price: ${basePriceValue}₽, Sale price: ${salePriceValue}₽, Discount: ${discountPercent.toFixed(1)}%`);
2522 }
2523 }
2524 }
2525 }
2526
2527 if (validDiscounts.length === 0) {
2528 console.error('Ozon Product Parser: No valid price pairs found');
2529 alert('Не удалось найти валидные пары цен на странице.');
2530 await GM.setValue('ozon_parser_calculate_discount', 'false');
2531 return;
2532 }
2533
2534 // Рассчитываем среднюю скидку
2535 const avgDiscount = validDiscounts.reduce((sum, item) => sum + item.discount, 0) / validDiscounts.length;
2536
2537 console.log(`Ozon Product Parser: Calculated average discount from ${validDiscounts.length} products: ${avgDiscount.toFixed(1)}%`);
2538
2539 // Сохраняем рассчитанную скидку для передачи в основное окно
2540 await GM.setValue('ozon_parser_calculated_discount', avgDiscount);
2541
2542 // Сбрасываем флаг расчета
2543 await GM.setValue('ozon_parser_calculate_discount', 'false');
2544
2545 // Закрываем текущую вкладку
2546 window.close();
2547 } catch (error) {
2548 console.error('Ozon Product Parser: Error extracting discount:', error);
2549 alert('Ошибка при извлечении данных о скидке: ' + error.message);
2550
2551 // Сбрасываем флаг расчета
2552 await GM.setValue('ozon_parser_calculate_discount', 'false');
2553 }
2554 }
2555
2556 // Ждем появления таблицы с ценами
2557 function waitForPricesTable() {
2558 return new Promise((resolve) => {
2559 const checkTable = () => {
2560 const rows = document.querySelectorAll('tr');
2561 if (rows.length > 0) {
2562 console.log('Ozon Product Parser: Prices table found');
2563 // Дополнительная задержка для полной загрузки данных
2564 setTimeout(resolve, 3000);
2565 } else {
2566 setTimeout(checkTable, 1000);
2567 }
2568 };
2569 checkTable();
2570 });
2571 }
2572
2573 // Прокручиваем страницу для подгрузки товаров
2574 async function scrollToLoadProducts(targetCount = 20) {
2575 console.log(`Ozon Product Parser: Scrolling to load ${targetCount} products`);
2576
2577 const table = document.querySelector('#mpstat-ozone-search-result table');
2578 if (!table) {
2579 console.error('Ozon Product Parser: Table not found for scrolling');
2580 return;
2581 }
2582
2583 let previousRowCount = 0;
2584 let attempts = 0;
2585 const maxAttempts = 10;
2586
2587 while (attempts < maxAttempts) {
2588 const rows = table.querySelectorAll('tbody tr');
2589 const currentRowCount = rows.length;
2590
2591 console.log(`Ozon Product Parser: Current row count: ${currentRowCount}, target: ${targetCount}`);
2592
2593 if (currentRowCount >= targetCount) {
2594 console.log(`Ozon Product Parser: Loaded ${currentRowCount} products`);
2595 break;
2596 }
2597
2598 // Если количество строк не изменилось, значит больше товаров нет
2599 if (currentRowCount === previousRowCount && attempts > 2) {
2600 console.log(`Ozon Product Parser: No more products to load (${currentRowCount} total)`);
2601 break;
2602 }
2603
2604 previousRowCount = currentRowCount;
2605
2606 // Прокручиваем к последней строке таблицы
2607 const lastRow = rows[rows.length - 1];
2608 if (lastRow) {
2609 lastRow.scrollIntoView({ behavior: 'smooth', block: 'end' });
2610 }
2611
2612 // Также прокручиваем окно вниз
2613 window.scrollBy(0, 500);
2614
2615 // Ждем подгрузки новых товаров
2616 await new Promise(resolve => setTimeout(resolve, 2000));
2617 attempts++;
2618 }
2619
2620 console.log(`Ozon Product Parser: Scrolling completed after ${attempts} attempts`);
2621 }
2622
2623 // Начинаем парсинг
2624 async function startParsing(queries, listName, modalContent) {
2625 const progressDiv = modalContent.querySelector('.ozon-parser-progress');
2626 progressDiv.style.display = 'block';
2627
2628 // Сохраняем список запросов и название списка
2629 await GM.setValue('ozon_parser_queries', JSON.stringify(queries));
2630 await GM.setValue('ozon_parser_current_list_name', listName);
2631 await GM.setValue('ozon_parser_current_index', 0);
2632 await GM.setValue('ozon_parser_active', 'true');
2633 console.log(`Ozon Product Parser: Starting parsing process for list "${listName}"`);
2634
2635 // Переходим к первому запросу
2636 const firstQuery = queries[0].trim();
2637 const searchUrl = `https://www.ozon.ru/search/?text=${encodeURIComponent(firstQuery)}&from_global=true`;
2638 window.location.href = searchUrl;
2639 }
2640
2641 // Продолжаем парсинг после загрузки страницы
2642 async function continueParsingIfActive() {
2643 const isActive = await GM.getValue('ozon_parser_active', 'false');
2644 if (isActive !== 'true') {
2645 return;
2646 }
2647
2648 console.log('Ozon Product Parser: Continuing parsing process');
2649
2650 // Ждем появления таблицы
2651 await waitForTable();
2652
2653 // Получаем текущее состояние
2654 const queriesJson = await GM.getValue('ozon_parser_queries', '[]');
2655 const queries = JSON.parse(queriesJson);
2656 const currentIndex = await GM.getValue('ozon_parser_current_index', 0);
2657 const currentListName = await GM.getValue('ozon_parser_current_list_name', 'Без названия');
2658
2659 // Получаем результаты для текущего списка
2660 const allListResultsJson = await GM.getValue('ozon_parser_list_results', '{}');
2661 const allListResults = JSON.parse(allListResultsJson);
2662
2663 if (!allListResults[currentListName]) {
2664 allListResults[currentListName] = {
2665 queries: {},
2666 createdAt: new Date().toISOString(),
2667 updatedAt: new Date().toISOString()
2668 };
2669 }
2670
2671 if (currentIndex >= queries.length) {
2672 // Парсинг завершен
2673 await GM.setValue('ozon_parser_active', 'false');
2674
2675 // Обновляем дату последнего обновления списка
2676 allListResults[currentListName].updatedAt = new Date().toISOString();
2677 await GM.setValue('ozon_parser_list_results', JSON.stringify(allListResults));
2678
2679 console.log('Ozon Product Parser: Parsing completed');
2680 return;
2681 }
2682
2683 const currentQuery = queries[currentIndex].trim();
2684 console.log(`Ozon Product Parser: Processing query ${currentIndex + 1}/${queries.length}: "${currentQuery}"`);
2685
2686 // Парсим данные текущей страницы
2687 const products = await parseProducts();
2688 allListResults[currentListName].queries[currentQuery] = products;
2689
2690 // Анализируем высокие цены
2691 await analyzeHighPrices(currentQuery, products);
2692
2693 // Сохраняем результаты
2694 await GM.setValue('ozon_parser_list_results', JSON.stringify(allListResults));
2695 console.log(`Ozon Product Parser: Parsed ${products.length} products for "${currentQuery}" in list "${currentListName}"`);
2696
2697 // Переходим к следующему запросу
2698 const nextIndex = currentIndex + 1;
2699 await GM.setValue('ozon_parser_current_index', nextIndex);
2700
2701 if (nextIndex < queries.length) {
2702 // Есть еще запросы - переходим к следующему
2703 const nextQuery = queries[nextIndex].trim();
2704 const searchUrl = `https://www.ozon.ru/search/?text=${encodeURIComponent(nextQuery)}&from_global=true`;
2705 setTimeout(() => {
2706 window.location.href = searchUrl;
2707 }, 2000); // Небольшая задержка между запросами
2708 } else {
2709 // Все запросы обработаны
2710 await GM.setValue('ozon_parser_active', 'false');
2711
2712 // Обновляем дату последнего обновления списка
2713 allListResults[currentListName].updatedAt = new Date().toISOString();
2714 await GM.setValue('ozon_parser_list_results', JSON.stringify(allListResults));
2715
2716 console.log('Ozon Product Parser: All queries processed');
2717
2718 // Показываем результаты
2719 setTimeout(() => {
2720 showResultsModal();
2721 }, 1000);
2722 }
2723 }
2724
2725 // Продолжаем расчет скидки после загрузки страницы
2726 async function continueDiscountCalculationIfActive() {
2727 const shouldCalculate = await GM.getValue('ozon_parser_calculate_discount', 'false');
2728 if (shouldCalculate === 'true') {
2729 await GM.setValue('ozon_parser_calculate_discount', 'false');
2730 console.log('Ozon Product Parser: Continuing discount calculation');
2731 await extractDiscountFromPage();
2732 }
2733 }
2734
2735 // Ждем появления таблицы
2736 function waitForTable() {
2737 return new Promise((resolve) => {
2738 const checkTable = () => {
2739 const table = document.querySelector('#mpstat-ozone-search-result table tbody');
2740 if (table && table.querySelectorAll('tr').length > 0) {
2741 console.log('Ozon Product Parser: Table found');
2742 // Дополнительная задержка для полной загрузки данных
2743 setTimeout(resolve, 5000);
2744 } else {
2745 setTimeout(checkTable, 1000);
2746 }
2747 };
2748 checkTable();
2749 });
2750 }
2751
2752 // Прокручиваем страницу для подгрузки товаров
2753 async function scrollToLoadProducts(targetCount = 20) {
2754 console.log(`Ozon Product Parser: Scrolling to load ${targetCount} products`);
2755
2756 const table = document.querySelector('#mpstat-ozone-search-result table');
2757 if (!table) {
2758 console.error('Ozon Product Parser: Table not found for scrolling');
2759 return;
2760 }
2761
2762 let previousRowCount = 0;
2763 let attempts = 0;
2764 const maxAttempts = 10;
2765
2766 while (attempts < maxAttempts) {
2767 const rows = table.querySelectorAll('tbody tr');
2768 const currentRowCount = rows.length;
2769
2770 console.log(`Ozon Product Parser: Current row count: ${currentRowCount}, target: ${targetCount}`);
2771
2772 if (currentRowCount >= targetCount) {
2773 console.log(`Ozon Product Parser: Loaded ${currentRowCount} products`);
2774 break;
2775 }
2776
2777 // Если количество строк не изменилось, значит больше товаров нет
2778 if (currentRowCount === previousRowCount && attempts > 2) {
2779 console.log(`Ozon Product Parser: No more products to load (${currentRowCount} total)`);
2780 break;
2781 }
2782
2783 previousRowCount = currentRowCount;
2784
2785 // Прокручиваем к последней строке таблицы
2786 const lastRow = rows[rows.length - 1];
2787 if (lastRow) {
2788 lastRow.scrollIntoView({ behavior: 'smooth', block: 'end' });
2789 }
2790
2791 // Также прокручиваем окно вниз
2792 window.scrollBy(0, 500);
2793
2794 // Ждем подгрузки новых товаров
2795 await new Promise(resolve => setTimeout(resolve, 2000));
2796 attempts++;
2797 }
2798
2799 console.log(`Ozon Product Parser: Scrolling completed after ${attempts} attempts`);
2800 }
2801
2802 // Парсим товары из таблицы
2803 async function parseProducts() {
2804 const table = document.querySelector('#mpstat-ozone-search-result table');
2805 if (!table) {
2806 console.error('Ozon Product Parser: Table not found');
2807 return [];
2808 }
2809
2810 // Прокручиваем страницу для подгрузки товаров
2811 await scrollToLoadProducts(20);
2812
2813 const rows = table.querySelectorAll('tbody tr');
2814 const products = [];
2815 const maxProducts = Math.min(20, rows.length);
2816
2817 // Получаем названия товаров из карточек на странице
2818 const productLinks = document.querySelectorAll('a[href*="/product/"]');
2819 const productNames = new Map();
2820
2821 console.log(`Ozon Product Parser: Found ${productLinks.length} product links`);
2822
2823 productLinks.forEach(link => {
2824 const href = link.getAttribute('href');
2825 const skuMatch = href.match(/\/product\/[^/]+-(\d+)/);
2826 if (skuMatch) {
2827 const sku = skuMatch[1];
2828 // Ищем название в родительском элементе
2829 const parent = link.closest('.tile-root') || link.closest('[data-index]');
2830 if (parent) {
2831 const nameElement = parent.querySelector('.tsBody500Medium');
2832 if (nameElement && nameElement.textContent.trim().length > 10) {
2833 productNames.set(sku, nameElement.textContent.trim());
2834 }
2835 }
2836 }
2837 });
2838
2839 console.log(`Ozon Product Parser: Extracted ${productNames.size} product names`);
2840
2841 // Функция для извлечения количества единиц из названия
2842 function extractQuantity(name) {
2843 if (!name) return null;
2844
2845 // Ищем паттерны: "120 капсул", "60 таблеток", "180шт", "90 шт"
2846 const patterns = [
2847 /(\d+)\s*(?:капсул|капс|caps)/i,
2848 /(\d+)\s*(?:таблеток|табл|tablets|tabs)/i,
2849 /(\d+)\s*(?:штук|шт|pcs|pieces)/i,
2850 /(\d+)\s*(?:порций|servings)/i
2851 ];
2852
2853 for (const pattern of patterns) {
2854 const match = name.match(pattern);
2855 if (match) {
2856 const quantity = parseInt(match[1]);
2857 if (quantity > 0 && quantity <= 1000) { // Разумные пределы
2858 return quantity;
2859 }
2860 }
2861 }
2862
2863 return null;
2864 }
2865
2866 for (let i = 0; i < maxProducts; i++) {
2867 const row = rows[i];
2868 const cells = row.querySelectorAll('td');
2869 if (cells.length < 8) continue;
2870
2871 const position = cells[0]?.textContent.trim() || '';
2872 const sku = cells[2]?.textContent.trim() || '';
2873 const brand = cells[3]?.textContent.trim() || '';
2874 const priceText = cells[4]?.textContent.trim() || '';
2875 const revenueText = cells[6]?.textContent.trim() || '';
2876 const ordersText = cells[7]?.textContent.trim() || '';
2877
2878 // Получаем название товара из карточки
2879 const name = productNames.get(sku) || '';
2880
2881 // Извлекаем количество единиц
2882 const quantity = extractQuantity(name);
2883
2884 // Проверяем, есть ли товар от GLS или Skinphoria
2885 const isTargetBrand = brand.includes('GLS Pharmaceuticals') || brand.includes('Skinphoria');
2886
2887 // Парсим числовые значения
2888 const price = parseFloat(priceText.replace(/[^\d]/g, '')) || 0;
2889 const revenue = parseFloat(revenueText.replace(/[^\d]/g, '')) || 0;
2890 const orders = parseInt(ordersText.replace(/[^\d]/g, '')) || 0;
2891
2892 // Рассчитываем цену за единицу
2893 const pricePerUnit = quantity && price > 0 ? price / quantity : null;
2894
2895 products.push({
2896 position: parseInt(position) || (i + 1),
2897 sku,
2898 name,
2899 brand,
2900 price,
2901 revenue,
2902 orders,
2903 isTargetBrand,
2904 quantity,
2905 pricePerUnit
2906 });
2907 }
2908
2909 // Сортируем по убыванию выручки
2910 products.sort((a, b) => b.revenue - a.revenue);
2911 console.log(`Ozon Product Parser: Parsed ${products.length} products, ${products.filter(p => p.isTargetBrand).length} from target brands`);
2912 return products;
2913 }
2914
2915 // Анализируем высокие цены после парсинга
2916 async function analyzeHighPrices(query, products) {
2917 console.log(`Ozon Product Parser: Analyzing high prices for query "${query}"`);
2918
2919 // Получаем топ-5 конкурентов по выручке
2920 const competitors = products.filter(p => !p.isTargetBrand && p.price > 0);
2921 const top5Competitors = competitors.slice(0, 5);
2922
2923 if (top5Competitors.length === 0) {
2924 console.log('Ozon Product Parser: No competitors found for high price analysis');
2925 return;
2926 }
2927
2928 // Находим минимальную цену среди топ-5 конкурентов
2929 const minCompetitorPrice = Math.min(...top5Competitors.map(p => p.price));
2930 console.log(`Ozon Product Parser: Min competitor price in top-5: ${minCompetitorPrice}₽`);
2931
2932 // Проверяем наши товары
2933 const ourProducts = products.filter(p => p.isTargetBrand);
2934 const highPriceProducts = [];
2935
2936 for (const product of ourProducts) {
2937 if (product.price > 0) {
2938 const priceDiff = ((product.price - minCompetitorPrice) / minCompetitorPrice) * 100;
2939
2940 if (priceDiff > 10) {
2941 console.log(`Ozon Product Parser: High price detected - SKU ${product.sku}: ${product.price}₽ vs ${minCompetitorPrice}₽ (+${priceDiff.toFixed(1)}%)`);
2942
2943 highPriceProducts.push({
2944 sku: product.sku,
2945 name: product.name,
2946 ourPrice: product.price,
2947 competitorPrice: minCompetitorPrice,
2948 query: query
2949 });
2950 }
2951 }
2952 }
2953
2954 if (highPriceProducts.length > 0) {
2955 // Добавляем в общий список высоких цен
2956 const existingDataJson = await GM.getValue('ozon_parser_high_price', '[]');
2957 const existingData = JSON.parse(existingDataJson);
2958
2959 // Удаляем дубликаты по SKU + query
2960 const existingKeys = new Set(existingData.map(item => `${item.sku}_${item.query}`));
2961 const newProducts = highPriceProducts.filter(item => !existingKeys.has(`${item.sku}_${item.query}`));
2962
2963 if (newProducts.length > 0) {
2964 const updatedData = [...existingData, ...newProducts];
2965 await GM.setValue('ozon_parser_high_price', JSON.stringify(updatedData));
2966 console.log(`Ozon Product Parser: Added ${newProducts.length} products to high price list`);
2967
2968 // Обновляем счетчик
2969 await updateHighPriceCounter();
2970 }
2971 }
2972 }
2973
2974 // Анализируем данные для запроса
2975 async function analyzeProducts(products) {
2976 console.log('Ozon Product Parser: Starting analysis for', products.length, 'products');
2977
2978 if (!products || products.length === 0) {
2979 console.error('Ozon Product Parser: No products to analyze');
2980 return null;
2981 }
2982
2983 try {
2984 // Получаем данные о расходах и скидке Ozon
2985 const costsJson = await GM.getValue('ozon_parser_costs', '{}');
2986 const costs = JSON.parse(costsJson);
2987 const ozonDiscount = await GM.getValue('ozon_parser_discount', 50);
2988 console.log(`Ozon Product Parser: Loaded costs for ${Object.keys(costs).length} SKUs, Ozon discount: ${ozonDiscount}%`);
2989
2990 // Общая выручка и заказы
2991 const totalRevenue = products.reduce((sum, p) => sum + p.revenue, 0);
2992 const totalOrders = products.reduce((sum, p) => sum + p.orders, 0);
2993
2994 // Топ-5 товаров по выручке
2995 const topProducts = products.slice(0, 5).map(p => ({
2996 name: p.name,
2997 brand: p.brand,
2998 price: p.price,
2999 revenue: p.revenue,
3000 orders: p.orders,
3001 position: p.position,
3002 revenueShare: ((p.revenue / totalRevenue) * 100).toFixed(1)
3003 }));
3004
3005 // Ценовые сегменты
3006 const prices = products.map(p => p.price).filter(p => p > 0);
3007 const minPrice = Math.min(...prices);
3008 const maxPrice = Math.max(...prices);
3009 const priceRange = maxPrice - minPrice;
3010 const segmentSize = priceRange / 4;
3011
3012 const priceSegments = [
3013 { min: minPrice, max: minPrice + segmentSize, name: 'Низкий' },
3014 { min: minPrice + segmentSize, max: minPrice + segmentSize * 2, name: 'Средний-' },
3015 { min: minPrice + segmentSize * 2, max: minPrice + segmentSize * 3, name: 'Средний+' },
3016 { min: minPrice + segmentSize * 3, max: maxPrice, name: 'Высокий' }
3017 ];
3018
3019 const segments = priceSegments.map(segment => {
3020 const segmentProducts = products.filter(p =>
3021 p.price >= segment.min && p.price <= segment.max
3022 );
3023 const segmentRevenue = segmentProducts.reduce((sum, p) => sum + p.revenue, 0);
3024 const segmentOrders = segmentProducts.reduce((sum, p) => sum + p.orders, 0);
3025
3026 return {
3027 name: segment.name,
3028 priceRange: `${Math.round(segment.min)} - ${Math.round(segment.max)} ₽`,
3029 count: segmentProducts.length,
3030 revenue: segmentRevenue,
3031 orders: segmentOrders,
3032 revenueShare: ((segmentRevenue / totalRevenue) * 100).toFixed(1),
3033 avgPrice: segmentProducts.length > 0
3034 ? Math.round(segmentProducts.reduce((sum, p) => sum + p.price, 0) / segmentProducts.length)
3035 : 0
3036 };
3037 }).filter(s => s.count > 0);
3038
3039 // Расчет эластичности запроса
3040 const competitors = products.filter(p => !p.isTargetBrand);
3041 let elasticity = null;
3042 let elasticityInterpretation = '';
3043
3044 if (competitors.length >= 5) {
3045 const logPrices = competitors.map(p => Math.log(p.price + 1));
3046 const logOrders = competitors.map(p => Math.log(p.orders + 1));
3047
3048 function simpleRegression(x, y) {
3049 const n = x.length;
3050 const sumX = x.reduce((a, b) => a + b, 0);
3051 const sumY = y.reduce((a, b) => a + b, 0);
3052 const sumXY = x.reduce((a, b, i) => a + b * y[i], 0);
3053 const sumX2 = x.reduce((a, b) => a + b * b, 0);
3054 const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
3055 return slope;
3056 }
3057
3058 const rawElasticity = simpleRegression(logPrices, logOrders);
3059 const priorElasticity = -1.2;
3060 const priorWeight = 0.2;
3061 elasticity = rawElasticity * (1 - priorWeight) + priorElasticity * priorWeight;
3062
3063 console.log(`Ozon Product Parser: Raw elasticity: ${rawElasticity.toFixed(2)}, Bayesian adjusted: ${elasticity.toFixed(2)}`);
3064
3065 if (elasticity < -1.5) {
3066 elasticityInterpretation = 'Высокая эластичность - спрос очень чувствителен к цене. Снижение цены сильно увеличит продажи.';
3067 } else if (elasticity < -0.8) {
3068 elasticityInterpretation = 'Средняя эластичность - спрос умеренно реагирует на изменение цены.';
3069 } else if (elasticity < 0) {
3070 elasticityInterpretation = 'Низкая эластичность - спрос слабо зависит от цены. Можно повышать цену.';
3071 } else {
3072 elasticityInterpretation = 'Аномальная эластичность - возможно недостаточно данных для анализа.';
3073 }
3074 }
3075
3076 // Функция для расчета прогноза
3077 function calculateForecast(newPrice, currentPrice, currentOrders, currentRevenue, productCosts) {
3078 const priceChange = (newPrice - currentPrice) / currentPrice;
3079 const usedElasticity = elasticity !== null ? elasticity : -1.2;
3080 const ordersChange = usedElasticity * priceChange;
3081
3082 const forecastOrders = Math.round(currentOrders * (1 + ordersChange));
3083 const forecastRevenue = Math.round(forecastOrders * newPrice);
3084
3085 let forecastProfit = null;
3086 let currentProfit = null;
3087 let profitChange = null;
3088
3089 if (productCosts) {
3090 const basePrice = newPrice / (1 - ozonDiscount / 100);
3091 const sellerRevenue = basePrice * (1 - productCosts.commission) * forecastOrders;
3092 const expenses = (productCosts.cost + productCosts.delivery) * forecastOrders;
3093 forecastProfit = sellerRevenue - expenses;
3094
3095 const currentBasePrice = currentPrice / (1 - ozonDiscount / 100);
3096 const currentSellerRevenue = currentBasePrice * (1 - productCosts.commission) * currentOrders;
3097 const currentExpenses = (productCosts.cost + productCosts.delivery) * currentOrders;
3098 currentProfit = currentSellerRevenue - currentExpenses;
3099
3100 profitChange = currentProfit > 0 ? Math.round(((forecastProfit - currentProfit) / currentProfit) * 100) : 0;
3101 }
3102
3103 return {
3104 orders: forecastOrders,
3105 ordersChange: Math.round(ordersChange * 100),
3106 revenue: forecastRevenue,
3107 revenueChange: Math.round(((forecastRevenue - currentRevenue) / currentRevenue) * 100),
3108 profit: forecastProfit,
3109 profitChange: profitChange
3110 };
3111 }
3112
3113 // Функция для расчета рекомендованной цены для конкретного товара
3114 function calculatePriceForProduct(targetProduct, allProducts) {
3115 console.log(`Calculating price for SKU ${targetProduct.sku}`);
3116
3117 const productCosts = costs[targetProduct.sku];
3118
3119 // Находим конкурентов
3120 const positionRange = 5;
3121 const nearbyProducts = allProducts.filter(p =>
3122 !p.isTargetBrand &&
3123 Math.abs(p.position - targetProduct.position) <= positionRange &&
3124 p.price > 0 && p.orders > 0
3125 );
3126
3127 const referenceProducts = nearbyProducts.length >= 3
3128 ? nearbyProducts
3129 : allProducts.filter(p => !p.isTargetBrand && p.price > 0 && p.orders > 0).slice(0, 10);
3130
3131 if (referenceProducts.length === 0) {
3132 return null;
3133 }
3134
3135 // Рассчитываем базовую оптимальную цену на основе конкурентов
3136 let weightedSum = 0;
3137 let weightSum = 0;
3138 referenceProducts.forEach(p => {
3139 const weight = p.revenue;
3140 weightedSum += p.price * weight;
3141 weightSum += weight;
3142 });
3143 const weightedPrice = weightSum > 0 ? weightedSum / weightSum : 0;
3144
3145 const productsWithConversion = referenceProducts.map(p => ({
3146 ...p,
3147 conversion: p.orders / p.price
3148 })).sort((a, b) => b.conversion - a.conversion);
3149 const topConversionPrice = productsWithConversion.length > 0
3150 ? productsWithConversion.slice(0, 3).reduce((sum, p) => sum + p.price, 0) / 3
3151 : 0;
3152
3153 const top10Prices = referenceProducts.slice(0, 10).map(p => p.price).sort((a, b) => a - b);
3154 const medianPrice = top10Prices.length > 0
3155 ? top10Prices[Math.floor(top10Prices.length / 2)]
3156 : 0;
3157
3158 const marketOptimalPrice = (weightedPrice * 0.4 + topConversionPrice * 0.3 + medianPrice * 0.3);
3159
3160 // Рассчитываем RPI
3161 const competitorPrices = allProducts.filter(p => !p.isTargetBrand && p.price > 0);
3162 let avgCompetitorPrice = 0;
3163 if (competitorPrices.length > 0) {
3164 let weightedPriceSum = 0;
3165 let weightSum = 0;
3166 competitorPrices.forEach(comp => {
3167 const weight = comp.revenue;
3168 weightedPriceSum += comp.price * weight;
3169 weightSum += weight;
3170 });
3171 avgCompetitorPrice = weightSum > 0 ? weightedPriceSum / weightSum : targetProduct.price;
3172 }
3173
3174 const rpi = avgCompetitorPrice > 0 ? (targetProduct.price / avgCompetitorPrice) * 100 : 100;
3175
3176 // Используем эластичность для оптимизации прибыли
3177 const usedElasticity = elasticity !== null ? elasticity : -1.2;
3178
3179 // Функция для расчета ожидаемой прибыли при заданной цене
3180 function calculateExpectedProfit(price) {
3181 if (!productCosts) return null;
3182
3183 const priceChange = (price - targetProduct.price) / targetProduct.price;
3184 const ordersChange = usedElasticity * priceChange;
3185 const expectedOrders = Math.max(1, targetProduct.orders * (1 + ordersChange));
3186
3187 const basePrice = price / (1 - ozonDiscount / 100);
3188 const sellerRevenue = basePrice * (1 - productCosts.commission) * expectedOrders;
3189 const expenses = (productCosts.cost + productCosts.delivery) * expectedOrders;
3190
3191 return sellerRevenue - expenses;
3192 }
3193
3194 // Рассчитываем 4 варианта цен с учетом эластичности и максимизации прибыли
3195 // Захват рынка: ищем оптимальную цену ниже текущей для РОСТА прибыли
3196 // Цена ОБЯЗАТЕЛЬНО ниже текущей, но прибыль должна РАСТИ за счет увеличения объема
3197 let marketCapturePrice = targetProduct.price;
3198 if (productCosts) {
3199 const currentProfit = calculateExpectedProfit(targetProduct.price);
3200 let bestPrice = targetProduct.price;
3201 let maxProfit = currentProfit;
3202
3203 // Ищем оптимум между -30% и -1% от текущей цены
3204 for (let multiplier = 0.70; multiplier < 0.99; multiplier += 0.01) {
3205 const testPrice = targetProduct.price * multiplier;
3206 const testProfit = calculateExpectedProfit(testPrice);
3207
3208 // Выбираем цену с максимальной прибылью (больше текущей)
3209 if (testProfit > maxProfit) {
3210 maxProfit = testProfit;
3211 bestPrice = testPrice;
3212 }
3213 }
3214
3215 // Если нашли цену с большей прибылью - используем её
3216 if (maxProfit > currentProfit) {
3217 marketCapturePrice = Math.round(bestPrice);
3218 } else {
3219 // Если не нашли - используем безопасное снижение на 5%
3220 marketCapturePrice = Math.round(targetProduct.price * 0.95);
3221 }
3222 } else {
3223 // Если нет данных о расходах, снижаем на 5%
3224 marketCapturePrice = Math.round(targetProduct.price * 0.95);
3225 }
3226
3227 // Агрессивная: ищем цену для максимизации прибыли с небольшим снижением
3228 // Допускаем падение прибыли до -2%, но стремимся к росту
3229 let aggressivePrice = targetProduct.price;
3230 if (productCosts) {
3231 const currentProfit = calculateExpectedProfit(targetProduct.price);
3232 let maxProfit = currentProfit;
3233 const minAcceptableProfit = currentProfit * 0.98; // Допускаем падение до -2%
3234
3235 // Ищем оптимум между -10% и +5% от текущей цены
3236 for (let multiplier = 0.90; multiplier <= 1.05; multiplier += 0.01) {
3237 const testPrice = targetProduct.price * multiplier;
3238 const testProfit = calculateExpectedProfit(testPrice);
3239
3240 // Выбираем цену с максимальной прибылью, но не ниже минимально допустимой
3241 if (testProfit >= minAcceptableProfit && testProfit > maxProfit) {
3242 maxProfit = testProfit;
3243 aggressivePrice = testPrice;
3244 }
3245 }
3246 aggressivePrice = Math.round(aggressivePrice);
3247 } else {
3248 // Если нет данных о расходах, умеренное снижение
3249 aggressivePrice = Math.round(targetProduct.price * 0.95);
3250 }
3251
3252 // Оптимальная: цена для максимизации прибыли (выше текущей)
3253 // Ищем оптимум между текущей ценой и +30%
3254 let optimalPrice = targetProduct.price;
3255 if (productCosts) {
3256 let maxProfit = calculateExpectedProfit(targetProduct.price);
3257 for (let multiplier = 1.05; multiplier <= 1.30; multiplier += 0.01) {
3258 const testPrice = targetProduct.price * multiplier;
3259 const testProfit = calculateExpectedProfit(testPrice);
3260 if (testProfit > maxProfit) {
3261 maxProfit = testProfit;
3262 optimalPrice = testPrice;
3263 }
3264 }
3265 optimalPrice = Math.round(optimalPrice);
3266 } else {
3267 // Если нет данных о расходах, используем рыночную цену
3268 optimalPrice = Math.round(Math.max(targetProduct.price * 1.10, marketOptimalPrice));
3269 }
3270
3271 // Рассчитываем базовые цены (цены поручения)
3272 const currentBasePrice = targetProduct.price / (1 - ozonDiscount / 100);
3273 const marketCaptureBasePrice = marketCapturePrice / (1 - ozonDiscount / 100);
3274 const aggressiveBasePrice = aggressivePrice / (1 - ozonDiscount / 100);
3275 const optimalBasePrice = optimalPrice / (1 - ozonDiscount / 100);
3276
3277 return {
3278 marketCapture: marketCapturePrice,
3279 aggressive: aggressivePrice,
3280 optimal: optimalPrice,
3281 currentPrice: targetProduct.price,
3282 currentBasePrice: Math.round(currentBasePrice),
3283 marketCaptureBasePrice: Math.round(marketCaptureBasePrice),
3284 aggressiveBasePrice: Math.round(aggressiveBasePrice),
3285 optimalBasePrice: Math.round(optimalBasePrice),
3286 currentPosition: targetProduct.position,
3287 currentProfit: null,
3288 rpi: rpi.toFixed(1),
3289 priceChange: {
3290 marketCapture: Math.round(((marketCapturePrice - targetProduct.price) / targetProduct.price * 100)),
3291 aggressive: Math.round(((aggressivePrice - targetProduct.price) / targetProduct.price * 100)),
3292 optimal: Math.round(((optimalPrice - targetProduct.price) / targetProduct.price * 100))
3293 },
3294 forecast: {
3295 marketCapture: calculateForecast(marketCapturePrice, targetProduct.price, targetProduct.orders, targetProduct.revenue, productCosts),
3296 aggressive: calculateForecast(aggressivePrice, targetProduct.price, targetProduct.orders, targetProduct.revenue, productCosts),
3297 optimal: calculateForecast(optimalPrice, targetProduct.price, targetProduct.orders, targetProduct.revenue, productCosts)
3298 }
3299 };
3300 }
3301
3302 // Общие рекомендации
3303 let weightedSum = 0;
3304 let weightSum = 0;
3305 products.forEach(p => {
3306 const positionBonus = p.position <= 5 ? 2 : 1;
3307 const weight = p.revenue * positionBonus;
3308 weightedSum += p.price * weight;
3309 weightSum += weight;
3310 });
3311 const weightedPrice = weightSum > 0 ? weightedSum / weightSum : 0;
3312
3313 const productsWithConversion = products.map(p => ({
3314 ...p,
3315 conversion: p.orders / p.price
3316 })).sort((a, b) => b.conversion - a.conversion);
3317 const topConversionPrice = productsWithConversion.length > 0
3318 ? productsWithConversion.slice(0, 3).reduce((sum, p) => sum + p.price, 0) / 3
3319 : 0;
3320
3321 const top10Prices = products.slice(0, 10).map(p => p.price).sort((a, b) => a - b);
3322 const medianPrice = top10Prices.length > 0
3323 ? top10Prices[Math.floor(top10Prices.length / 2)]
3324 : 0;
3325
3326 const baseOptimalPrice = (weightedPrice * 0.4 + topConversionPrice * 0.3 + medianPrice * 0.3);
3327
3328 const recommendedPrices = {
3329 marketCapture: {
3330 price: Math.round(baseOptimalPrice * 0.70),
3331 strategy: 'Захват рынка',
3332 description: 'Оптимальная цена ниже текущей для максимизации прибыли'
3333 },
3334 aggressive: {
3335 price: Math.round(baseOptimalPrice * 0.85),
3336 strategy: 'Агрессивная',
3337 description: 'Низкая цена для максимальных продаж и быстрого роста позиций'
3338 },
3339 optimal: {
3340 price: Math.round(baseOptimalPrice),
3341 strategy: 'Оптимальная',
3342 description: 'Баланс между прибылью и объемом продаж'
3343 }
3344 };
3345
3346 // Раздельные рекомендации для целевых брендов
3347 const glsProducts = products.filter(p => p.brand.includes('GLS Pharmaceuticals'));
3348 const skinphoriaProducts = products.filter(p => p.brand.includes('Skinphoria'));
3349
3350 const brandRecommendations = {};
3351
3352 // Рекомендации для GLS Pharmaceuticals
3353 if (glsProducts.length > 0) {
3354 const glsAvgPosition = glsProducts.reduce((sum, p) => sum + p.position, 0) / glsProducts.length;
3355 const glsAvgPrice = glsProducts.reduce((sum, p) => sum + p.price, 0) / glsProducts.length;
3356
3357 brandRecommendations.gls = {
3358 brand: 'GLS Pharmaceuticals',
3359 currentProducts: glsProducts.map(p => {
3360 const priceRec = calculatePriceForProduct(p, products);
3361 return {
3362 sku: p.sku,
3363 name: p.name,
3364 position: p.position,
3365 price: p.price,
3366 revenue: p.revenue,
3367 orders: p.orders,
3368 recommendations: priceRec
3369 };
3370 }),
3371 avgPosition: Math.round(glsAvgPosition),
3372 avgPrice: Math.round(glsAvgPrice)
3373 };
3374 }
3375
3376 // Рекомендации для Skinphoria
3377 if (skinphoriaProducts.length > 0) {
3378 const skinphoriaAvgPosition = skinphoriaProducts.reduce((sum, p) => sum + p.position, 0) / skinphoriaProducts.length;
3379 const skinphoriaAvgPrice = skinphoriaProducts.reduce((sum, p) => sum + p.price, 0) / skinphoriaProducts.length;
3380
3381 brandRecommendations.skinphoria = {
3382 brand: 'Skinphoria',
3383 currentProducts: skinphoriaProducts.map(p => {
3384 const priceRec = calculatePriceForProduct(p, products);
3385 return {
3386 sku: p.sku,
3387 name: p.name,
3388 position: p.position,
3389 price: p.price,
3390 revenue: p.revenue,
3391 orders: p.orders,
3392 recommendations: priceRec
3393 };
3394 }),
3395 avgPosition: Math.round(skinphoriaAvgPosition),
3396 avgPrice: Math.round(skinphoriaAvgPrice)
3397 };
3398 }
3399
3400 console.log('Ozon Product Parser: Analysis completed successfully');
3401
3402 return {
3403 totalRevenue,
3404 totalOrders,
3405 avgPrice: Math.round(totalRevenue / totalOrders),
3406 topProducts,
3407 priceSegments: segments,
3408 elasticity: elasticity !== null ? {
3409 value: elasticity.toFixed(2),
3410 interpretation: elasticityInterpretation
3411 } : null,
3412 recommendedPrices,
3413 brandRecommendations
3414 };
3415 } catch (error) {
3416 console.error('Ozon Product Parser: Error in analyzeProducts:', error);
3417 return null;
3418 }
3419 }
3420
3421 // Показываем результаты
3422 async function showResultsModal() {
3423 console.log('Ozon Product Parser: Opening results modal');
3424
3425 try {
3426 // Получаем результаты по спискам
3427 const allListResultsJson = await GM.getValue('ozon_parser_list_results', '{}');
3428 const allListResults = JSON.parse(allListResultsJson);
3429 const listNames = Object.keys(allListResults);
3430
3431 if (listNames.length === 0) {
3432 alert('Нет сохраненных результатов. Сначала выполните парсинг.');
3433 return;
3434 }
3435
3436 const modal = document.createElement('div');
3437 modal.className = 'ozon-parser-modal';
3438
3439 const content = document.createElement('div');
3440 content.className = 'ozon-parser-modal-content';
3441 content.style.maxWidth = '95vw';
3442 content.style.width = '95vw';
3443
3444 content.innerHTML = `
3445 <div class="ozon-parser-modal-header">Результаты парсинга</div>
3446 <div class="ozon-parser-modal-body">
3447 <div style="margin-bottom: 20px;">
3448 <label style="font-size: 14px; font-weight: 600; margin-bottom: 8px; display: block;">Выберите список:</label>
3449 <div style="display: flex; gap: 10px; align-items: center;">
3450 <select class="ozon-parser-search" id="list-selector" style="flex: 1; margin-bottom: 0;">
3451 ${listNames.map(listName => {
3452 const list = allListResults[listName];
3453 const date = new Date(list.updatedAt).toLocaleDateString('ru-RU');
3454 const queriesCount = Object.keys(list.queries).length;
3455 return `<option value="${listName}">${listName} (${queriesCount} запросов, обновлен: ${date})</option>`;
3456 }).join('')}
3457 </select>
3458 <button class="ozon-parser-btn" id="delete-list-btn" style="background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);">Удалить список</button>
3459 </div>
3460 </div>
3461 <div style="margin-bottom: 15px;">
3462 <label style="font-size: 14px; font-weight: 600; margin-bottom: 8px; display: block;">Сортировка запросов:</label>
3463 <select class="ozon-parser-search" id="query-sort-selector" style="margin-bottom: 0;">
3464 <option value="revenue">По нашей выручке (убывание)</option>
3465 <option value="alphabet">По алфавиту (А-Я)</option>
3466 </select>
3467 </div>
3468 <input type="text" class="ozon-parser-search" id="query-search" placeholder="Поиск по запросам">
3469 <input type="text" class="ozon-parser-search" id="sku-search" placeholder="Поиск по SKU">
3470 <div class="ozon-parser-tabs" id="query-tabs"></div>
3471 <div id="results-container"></div>
3472 </div>
3473 <div class="ozon-parser-modal-footer">
3474 <button class="ozon-parser-btn" id="close-results-btn">Закрыть</button>
3475 </div>
3476 `;
3477
3478 modal.appendChild(content);
3479 document.body.appendChild(modal);
3480
3481 // Текущий выбранный список
3482 let currentListName = listNames[0];
3483 let currentResults = allListResults[currentListName].queries;
3484 let currentSortMode = 'revenue';
3485
3486 // Функция для сортировки запросов
3487 function sortQueries(queries, results, sortMode) {
3488 if (sortMode === 'alphabet') {
3489 return queries.sort((a, b) => a.localeCompare(b, 'ru'));
3490 } else {
3491 // Сортировка по нашей выручке
3492 return queries.sort((a, b) => {
3493 const productsA = results[a];
3494 const productsB = results[b];
3495
3496 // Находим наши товары и их выручку
3497 const ourProductsA = productsA.filter(p => p.isTargetBrand);
3498 const ourProductsB = productsB.filter(p => p.isTargetBrand);
3499
3500 const revenueA = ourProductsA.reduce((sum, p) => sum + p.revenue, 0);
3501 const revenueB = ourProductsB.reduce((sum, p) => sum + p.revenue, 0);
3502
3503 return revenueB - revenueA; // По убыванию
3504 });
3505 }
3506 }
3507
3508 // Функция для обновления отображения
3509 async function updateDisplay() {
3510 const queries = Object.keys(currentResults);
3511 const sortedQueries = sortQueries(queries, currentResults, currentSortMode);
3512
3513 // Создаем вкладки
3514 createTabs(sortedQueries, currentResults, content);
3515
3516 // Показываем результаты первого запроса
3517 if (sortedQueries.length > 0) {
3518 await displayResults(sortedQueries[0], currentResults[sortedQueries[0]], content);
3519 }
3520 }
3521
3522 // Обработчик выбора списка
3523 const listSelector = content.querySelector('#list-selector');
3524 listSelector.addEventListener('change', () => {
3525 currentListName = listSelector.value;
3526 currentResults = allListResults[currentListName].queries;
3527 updateDisplay();
3528 });
3529
3530 // Обработчик изменения сортировки
3531 const querySortSelector = content.querySelector('#query-sort-selector');
3532 querySortSelector.addEventListener('change', () => {
3533 currentSortMode = querySortSelector.value;
3534 updateDisplay();
3535 });
3536
3537 // Обработчик удаления списка
3538 const deleteListBtn = content.querySelector('#delete-list-btn');
3539 deleteListBtn.addEventListener('click', async () => {
3540 if (!confirm(`Вы уверены, что хотите удалить список "${currentListName}"?`)) {
3541 return;
3542 }
3543
3544 // Удаляем список
3545 delete allListResults[currentListName];
3546 await GM.setValue('ozon_parser_list_results', JSON.stringify(allListResults));
3547
3548 // Также удаляем из сохраненных списков
3549 const savedListsJson = await GM.getValue('ozon_parser_saved_lists', '{}');
3550 const savedLists = JSON.parse(savedListsJson);
3551 delete savedLists[currentListName];
3552 await GM.setValue('ozon_parser_saved_lists', JSON.stringify(savedLists));
3553
3554 console.log(`Ozon Product Parser: List "${currentListName}" deleted`);
3555
3556 // Обновляем UI
3557 const remainingLists = Object.keys(allListResults);
3558 if (remainingLists.length === 0) {
3559 alert('Все списки удалены');
3560 modal.remove();
3561 return;
3562 }
3563
3564 // Переключаемся на первый оставшийся список
3565 currentListName = remainingLists[0];
3566 currentResults = allListResults[currentListName].queries;
3567
3568 // Обновляем селектор
3569 listSelector.innerHTML = remainingLists.map(listName => {
3570 const list = allListResults[listName];
3571 const date = new Date(list.updatedAt).toLocaleDateString('ru-RU');
3572 const queriesCount = Object.keys(list.queries).length;
3573 return `<option value="${listName}">${listName} (${queriesCount} запросов, обновлен: ${date})</option>`;
3574 }).join('');
3575
3576 updateDisplay();
3577 });
3578
3579 // Обработчик закрытия модального окна
3580 const closeBtn = content.querySelector('#close-results-btn');
3581 closeBtn.addEventListener('click', () => {
3582 modal.remove();
3583 });
3584
3585 // Обработчик поиска по запросам
3586 const querySearchInput = content.querySelector('#query-search');
3587 querySearchInput.addEventListener('input', debounce(() => {
3588 const searchValue = querySearchInput.value.trim().toLowerCase();
3589 const queries = Object.keys(currentResults);
3590 const sortedQueries = sortQueries(queries, currentResults, currentSortMode);
3591 filterQueriesByQuery(searchValue, sortedQueries, currentResults, content);
3592 }, 300));
3593
3594 // Обработчик поиска по SKU
3595 const skuSearchInput = content.querySelector('#sku-search');
3596 skuSearchInput.addEventListener('input', debounce(() => {
3597 const searchValue = skuSearchInput.value.trim();
3598 const queries = Object.keys(currentResults);
3599 const sortedQueries = sortQueries(queries, currentResults, currentSortMode);
3600 filterQueriesBySKU(searchValue, sortedQueries, currentResults, content);
3601 }, 300));
3602
3603 // Создаем вкладки для запросов
3604 const initialQueries = Object.keys(currentResults);
3605 const sortedInitialQueries = sortQueries(initialQueries, currentResults, currentSortMode);
3606 createTabs(sortedInitialQueries, currentResults, content);
3607
3608 // Показываем результаты первого запроса
3609 await displayResults(sortedInitialQueries[0], currentResults[sortedInitialQueries[0]], content);
3610
3611 // Закрытие по клику на фон
3612 modal.addEventListener('click', (e) => {
3613 if (e.target === modal) {
3614 modal.remove();
3615 }
3616 });
3617
3618 console.log('Ozon Product Parser: Results modal shown');
3619 } catch (error) {
3620 console.error('Ozon Product Parser: Error in showResultsModal:', error);
3621 alert('Ошибка при отображении результатов: ' + error.message);
3622 }
3623 }
3624
3625 // Фильтруем запросы по названию запроса
3626 async function filterQueriesByQuery(queryText, allQueries, results, modalContent) {
3627 if (!queryText) {
3628 createTabs(allQueries, results, modalContent);
3629 await displayResults(allQueries[0], results[allQueries[0]], modalContent);
3630 return;
3631 }
3632
3633 const filteredQueries = allQueries.filter(q =>
3634 q.toLowerCase().includes(queryText)
3635 );
3636
3637 if (filteredQueries.length === 0) {
3638 const container = modalContent.querySelector('#results-container');
3639 container.innerHTML = '<p>Запросы не найдены</p>';
3640 const tabsContainer = modalContent.querySelector('#query-tabs');
3641 tabsContainer.innerHTML = '';
3642 return;
3643 }
3644
3645 createTabs(filteredQueries, results, modalContent);
3646 await displayResults(filteredQueries[0], results[filteredQueries[0]], modalContent);
3647 }
3648
3649 // Фильтруем запросы по SKU
3650 async function filterQueriesBySKU(sku, allQueries, results, modalContent) {
3651 if (!sku) {
3652 createTabs(allQueries, results, modalContent);
3653 await displayResults(allQueries[0], results[allQueries[0]], modalContent);
3654 return;
3655 }
3656
3657 const filteredQueries = allQueries.filter(query => {
3658 const products = results[query];
3659 return products.some(product => product.sku.includes(sku));
3660 });
3661
3662 if (filteredQueries.length === 0) {
3663 const container = modalContent.querySelector('#results-container');
3664 container.innerHTML = '<p>Товары с таким SKU не найдены ни в одном запросе</p>';
3665 const tabsContainer = modalContent.querySelector('#query-tabs');
3666 tabsContainer.innerHTML = '';
3667 return;
3668 }
3669
3670 createTabs(filteredQueries, results, modalContent);
3671 await displayResults(filteredQueries[0], results[filteredQueries[0]], modalContent);
3672 }
3673
3674 // Создаем вкладки для запросов
3675 function createTabs(queries, results, modalContent) {
3676 const tabsContainer = modalContent.querySelector('#query-tabs');
3677 tabsContainer.innerHTML = '';
3678
3679 queries.forEach((query, index) => {
3680 const tab = document.createElement('button');
3681 tab.className = 'ozon-parser-tab' + (index === 0 ? ' active' : '');
3682
3683 // Получаем позицию по выручке
3684 const products = results[query];
3685 const position = getOurRevenuePosition(query, products);
3686
3687 // Формируем текст вкладки
3688 if (position !== null) {
3689 tab.textContent = `${query} (${position} место)`;
3690 } else {
3691 tab.textContent = `${query} (нет наших)`;
3692 }
3693
3694 tab.addEventListener('click', async () => {
3695 tabsContainer.querySelectorAll('.ozon-parser-tab').forEach(t => t.classList.remove('active'));
3696 tab.classList.add('active');
3697 await displayResults(query, results[query], modalContent);
3698 });
3699 tabsContainer.appendChild(tab);
3700 });
3701
3702 // Функция для получения нашей позиции по выручке в запросе
3703 function getOurRevenuePosition(query, products) {
3704 // Сортируем все товары по выручке
3705 const sortedByRevenue = [...products].sort((a, b) => b.revenue - a.revenue);
3706
3707 // Находим первый наш товар
3708 const ourProductIndex = sortedByRevenue.findIndex(p => p.isTargetBrand);
3709
3710 if (ourProductIndex === -1) {
3711 return null; // Наших товаров нет
3712 }
3713
3714 return ourProductIndex + 1; // Позиция (1-based)
3715 }
3716 }
3717
3718 // Отображаем результаты для конкретного запроса
3719 async function displayResults(query, productsData, modalContent) {
3720 console.log('Ozon Product Parser: Displaying results for query:', query);
3721
3722 const container = modalContent.querySelector('#results-container');
3723
3724 try {
3725 let products;
3726
3727 if (Array.isArray(productsData)) {
3728 products = productsData;
3729 } else {
3730 container.innerHTML = '<p>Запросы не найдены</p>';
3731 return;
3732 }
3733
3734 if (!products || products.length === 0) {
3735 container.innerHTML = '<p>Нет данных для этого запроса</p>';
3736 return;
3737 }
3738
3739 // Получаем аналитику
3740 const analytics = await analyzeProducts(products);
3741
3742 if (!analytics) {
3743 container.innerHTML = '<p>Ошибка при анализе данных</p>';
3744 return;
3745 }
3746
3747 // Получаем скидку Ozon для расчета базовой цены
3748 const ozonDiscount = await GM.getValue('ozon_parser_discount', 50);
3749
3750 let tableHTML = `
3751 <table class="ozon-parser-results-table">
3752 <thead>
3753 <tr>
3754 <th>Позиция</th>
3755 <th>SKU</th>
3756 <th>Название</th>
3757 <th>Бренд</th>
3758 <th>Текущая цена</th>
3759 <th>Средняя цена</th>
3760 <th>Выручка</th>
3761 <th>Заказы</th>
3762 </tr>
3763 </thead>
3764 <tbody>
3765 `;
3766
3767 products.forEach(product => {
3768 const rowClass = product.isTargetBrand ? ' class="ozon-parser-highlight"' : '';
3769 const skuLink = `https://www.ozon.ru/product/${product.sku}`;
3770 const basePrice = product.price / (1 - ozonDiscount / 100);
3771 const avgPrice = product.orders > 0 ? product.revenue / product.orders : 0;
3772
3773 tableHTML += `
3774 <tr${rowClass}>
3775 <td>${product.position}</td>
3776 <td><a href="${skuLink}" target="_blank" class="ozon-parser-sku-link">${product.sku}</a></td>
3777 <td>${product.name || '—'}</td>
3778 <td>${product.brand}</td>
3779 <td>
3780 <div style="font-weight: 600;">${product.price.toLocaleString('ru-RU')} ₽</div>
3781 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${Math.round(basePrice).toLocaleString('ru-RU')} ₽</div>
3782 </td>
3783 <td>
3784 <div style="font-weight: 600;">${Math.round(avgPrice).toLocaleString('ru-RU')} ₽</div>
3785 </td>
3786 <td>${product.revenue.toLocaleString('ru-RU')} ₽</td>
3787 <td>${product.orders.toLocaleString('ru-RU')}</td>
3788 </tr>
3789 `;
3790 });
3791
3792 tableHTML += `
3793 </tbody>
3794 </table>
3795 `;
3796
3797 // Добавляем аналитику
3798 tableHTML += `
3799 <div class="ozon-parser-analytics">
3800 <div class="ozon-parser-analytics-header">📊 Аналитика запроса</div>
3801
3802 ${analytics.elasticity ? `
3803 <div class="ozon-parser-analytics-section">
3804 <div class="ozon-parser-analytics-card">
3805 <div class="ozon-parser-analytics-card-title">Эластичность запроса</div>
3806 <div class="ozon-parser-analytics-card-value">${analytics.elasticity.value}</div>
3807 <div style="font-size: 12px; color: #666; margin-top: 8px;">
3808 ${analytics.elasticity.interpretation}
3809 </div>
3810 </div>
3811 </div>
3812 ` : ''}
3813
3814 <div class="ozon-parser-analytics-section">
3815 <div class="ozon-parser-analytics-section-title">Рекомендованные цены</div>
3816 <div class="ozon-parser-analytics-grid">
3817 <div class="ozon-parser-analytics-card" style="border: 2px solid #6c757d;">
3818 <div class="ozon-parser-analytics-card-title">⚡ Захват рынка</div>
3819 <div class="ozon-parser-analytics-card-value" style="color: #6c757d;">${analytics.recommendedPrices.marketCapture.price.toLocaleString('ru-RU')} ₽</div>
3820 <div style="font-size: 12px; color: #666; margin-top: 8px;">Оптимальная цена ниже текущей для максимизации прибыли</div>
3821 </div>
3822 <div class="ozon-parser-analytics-card" style="border: 2px solid #dc3545;">
3823 <div class="ozon-parser-analytics-card-title">✅ Оптимальная</div>
3824 <div class="ozon-parser-analytics-card-value" style="color: #dc3545;">${analytics.recommendedPrices.aggressive.price.toLocaleString('ru-RU')} ₽</div>
3825 <div style="font-size: 12px; color: #666; margin-top: 8px;">Низкая цена для максимальных продаж и быстрого роста позиций</div>
3826 </div>
3827 <div class="ozon-parser-analytics-card" style="border: 2px solid #28a745;">
3828 <div class="ozon-parser-analytics-card-title">🔥 Агрессивная</div>
3829 <div class="ozon-parser-analytics-card-value" style="color: #28a745;">${analytics.recommendedPrices.optimal.price.toLocaleString('ru-RU')} ₽</div>
3830 <div style="font-size: 12px; color: #666; margin-top: 8px;">Баланс между прибылью и объемом продаж</div>
3831 </div>
3832 </div>
3833 <div class="ozon-parser-info">
3834 💡 Расчет учитывает: средневзвешенную цену по выручке (вес 40%), оптимальную конверсию цена/заказы (вес 30%), медианную цену топ-10 (вес 30%). Товары из топ-5 позиций имеют удвоенный вес.
3835 </div>
3836 </div>
3837
3838 ${analytics.brandRecommendations && Object.keys(analytics.brandRecommendations).length > 0 ? `
3839 <div class="ozon-parser-analytics-section">
3840 <div class="ozon-parser-analytics-section-title">🎯 Рекомендации для ваших брендов</div>
3841 ${analytics.brandRecommendations.gls ? `
3842 <div style="background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; border: 2px solid #ffc107;">
3843 <div style="font-size: 18px; font-weight: 600; margin-bottom: 15px; color: #333;">
3844 ${analytics.brandRecommendations.gls.brand}
3845 </div>
3846 <div style="margin-bottom: 15px;">
3847 <div style="font-size: 13px; color: #666; margin-bottom: 5px;">Текущие товары в выдаче: ${analytics.brandRecommendations.gls.currentProducts.length}</div>
3848 <div style="font-size: 13px; color: #666; margin-bottom: 5px;">Средняя позиция: ${analytics.brandRecommendations.gls.avgPosition}</div>
3849 <div style="font-size: 13px; color: #666;">Средняя цена: ${analytics.brandRecommendations.gls.avgPrice.toLocaleString('ru-RU')} ₽</div>
3850 </div>
3851 <details style="margin-top: 10px;" open>
3852 <summary style="cursor: pointer; font-size: 13px; color: #005bff; font-weight: 600;">Показать товары</summary>
3853 <table class="ozon-parser-analytics-table" style="margin-top: 10px;">
3854 <thead>
3855 <tr>
3856 <th>SKU</th>
3857 <th>Название</th>
3858 <th>Позиция</th>
3859 <th>Текущая цена</th>
3860 <th>⚡ Захват рынка</th>
3861 <th>✅ Оптимальная</th>
3862 <th>🔥 Агрессивная</th>
3863 </tr>
3864 </thead>
3865 <tbody>
3866 ${analytics.brandRecommendations.gls.currentProducts.map(p => {
3867 const rec = p.recommendations;
3868 if (!rec) return `
3869 <tr>
3870 <td><a href="https://www.ozon.ru/product/${p.sku}" target="_blank" class="ozon-parser-sku-link">${p.sku}</a></td>
3871 <td style="max-width: 200px; white-space: normal;">${p.name || '—'}</td>
3872 <td>${p.position}</td>
3873 <td>${p.price.toLocaleString('ru-RU')} ₽</td>
3874 <td>—</td>
3875 <td>—</td>
3876 <td>—</td>
3877 </tr>
3878 `;
3879 return `
3880 <tr>
3881 <td><a href="https://www.ozon.ru/product/${p.sku}" target="_blank" class="ozon-parser-sku-link">${p.sku}</a></td>
3882 <td style="max-width: 200px; white-space: normal;">${p.name || '—'}</td>
3883 <td>${p.position}</td>
3884 <td>
3885 <div style="font-weight: 600;">${p.price.toLocaleString('ru-RU')} ₽</div>
3886 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.currentBasePrice.toLocaleString('ru-RU')} ₽</div>
3887 <div style="font-size: 11px; color: #666; margin-top: 4px;">Выручка: ${p.revenue.toLocaleString('ru-RU')} ₽</div>
3888 <div style="font-size: 11px; color: #666;">Заказы: ${p.orders.toLocaleString('ru-RU')}</div>
3889 ${rec.skuDiscount ? `
3890 <div style="font-size: 11px; color: #ff6b00; font-weight: 600; margin-top: 4px;">
3891 Скидка Ozon: ${rec.skuDiscount}%
3892 </div>
3893 ` : ''}
3894 ${rec.currentProfit !== null ? `
3895 <div style="font-size: 11px; color: #005bff; font-weight: 600; margin-top: 4px;">
3896 Прибыль: ${rec.currentProfit.toFixed(0)} ₽
3897 </div>
3898 ` : ''}
3899 </td>
3900 <td style="background: #e9ecef;">
3901 <div style="font-weight: 600; color: #6c757d;">${rec.marketCapture.toLocaleString('ru-RU')} ₽</div>
3902 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.marketCaptureBasePrice.toLocaleString('ru-RU')} ₽</div>
3903 <div style="font-size: 11px; color: ${rec.priceChange.marketCapture < 0 ? '#dc3545' : '#28a745'};">
3904 ${rec.priceChange.marketCapture > 0 ? '+' : ''}${rec.priceChange.marketCapture}%
3905 </div>
3906 ${rec.forecast && rec.forecast.marketCapture ? `
3907 <div style="font-size: 11px; color: ${rec.forecast.marketCapture.revenueChange >= 0 ? '#28a745' : '#dc3545'}; margin-top: 4px;">
3908 Выручка: ${rec.forecast.marketCapture.revenueChange > 0 ? '+' : ''}${rec.forecast.marketCapture.revenueChange}%
3909 </div>
3910 <div style="font-size: 11px; color: ${rec.forecast.marketCapture.ordersChange >= 0 ? '#28a745' : '#dc3545'};">
3911 Заказы: ${rec.forecast.marketCapture.ordersChange > 0 ? '+' : ''}${rec.forecast.marketCapture.ordersChange}%
3912 </div>
3913 ${rec.forecast.marketCapture.profit !== null ? `
3914 <div style="font-size: 11px; color: ${rec.forecast.marketCapture.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600; margin-top: 2px;">
3915 Прибыль: ${rec.forecast.marketCapture.profitChange > 0 ? '+' : ''}${rec.forecast.marketCapture.profitChange}%
3916 </div>
3917 <div style="font-size: 10px; color: #666;">
3918 ${rec.forecast.marketCapture.profit.toFixed(0)} ₽
3919 </div>
3920 ` : ''}
3921 ` : ''}
3922 </td>
3923 <td style="background: #d4edda;">
3924 <div style="font-weight: 600; color: #28a745;">${rec.aggressive.toLocaleString('ru-RU')} ₽</div>
3925 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.aggressiveBasePrice.toLocaleString('ru-RU')} ₽</div>
3926 <div style="font-size: 11px; color: ${rec.priceChange.aggressive < 0 ? '#dc3545' : '#28a745'};">
3927 ${rec.priceChange.aggressive > 0 ? '+' : ''}${rec.priceChange.aggressive}%
3928 </div>
3929 ${rec.forecast && rec.forecast.aggressive ? `
3930 <div style="font-size: 11px; color: ${rec.forecast.aggressive.revenueChange >= 0 ? '#28a745' : '#dc3545'}; margin-top: 4px;">
3931 Выручка: ${rec.forecast.aggressive.revenueChange > 0 ? '+' : ''}${rec.forecast.aggressive.revenueChange}%
3932 </div>
3933 <div style="font-size: 11px; color: ${rec.forecast.aggressive.ordersChange >= 0 ? '#28a745' : '#dc3545'};">
3934 Заказы: ${rec.forecast.aggressive.ordersChange > 0 ? '+' : ''}${rec.forecast.aggressive.ordersChange}%
3935 </div>
3936 ${rec.forecast.aggressive.profit !== null ? `
3937 <div style="font-size: 11px; color: ${rec.forecast.aggressive.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600; margin-top: 2px;">
3938 Прибыль: ${rec.forecast.aggressive.profitChange > 0 ? '+' : ''}${rec.forecast.aggressive.profitChange}%
3939 </div>
3940 <div style="font-size: 10px; color: #666;">
3941 ${rec.forecast.aggressive.profit.toFixed(0)} ₽
3942 </div>
3943 ` : ''}
3944 ` : ''}
3945 </td>
3946 <td style="background: #fff3cd;">
3947 <div style="font-weight: 600; color: #dc3545;">${rec.optimal.toLocaleString('ru-RU')} ₽</div>
3948 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.optimalBasePrice.toLocaleString('ru-RU')} ₽</div>
3949 <div style="font-size: 11px; color: ${rec.priceChange.optimal < 0 ? '#dc3545' : '#28a745'};">
3950 ${rec.priceChange.optimal > 0 ? '+' : ''}${rec.priceChange.optimal}%
3951 </div>
3952 ${rec.forecast && rec.forecast.optimal ? `
3953 <div style="font-size: 11px; color: ${rec.forecast.optimal.revenueChange >= 0 ? '#28a745' : '#dc3545'}; margin-top: 4px;">
3954 Выручка: ${rec.forecast.optimal.revenueChange > 0 ? '+' : ''}${rec.forecast.optimal.revenueChange}%
3955 </div>
3956 <div style="font-size: 11px; color: ${rec.forecast.optimal.ordersChange >= 0 ? '#28a745' : '#dc3545'};">
3957 Заказы: ${rec.forecast.optimal.ordersChange > 0 ? '+' : ''}${rec.forecast.optimal.ordersChange}%
3958 </div>
3959 ${rec.forecast.optimal.profit !== null ? `
3960 <div style="font-size: 11px; color: ${rec.forecast.optimal.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600; margin-top: 2px;">
3961 Прибыль: ${rec.forecast.optimal.profitChange > 0 ? '+' : ''}${rec.forecast.optimal.profitChange}%
3962 </div>
3963 <div style="font-size: 10px; color: #666;">
3964 ${rec.forecast.optimal.profit.toFixed(0)} ₽
3965 </div>
3966 ` : ''}
3967 ` : ''}
3968 </td>
3969 </tr>
3970 `;
3971 }).join('')}
3972 </tbody>
3973 </table>
3974 </details>
3975 </div>
3976 ` : ''}
3977 ${analytics.brandRecommendations.skinphoria ? `
3978 <div style="background: white; padding: 20px; border-radius: 8px; border: 2px solid #ffc107;">
3979 <div style="font-size: 18px; font-weight: 600; margin-bottom: 15px; color: #333;">
3980 ${analytics.brandRecommendations.skinphoria.brand}
3981 </div>
3982 <div style="margin-bottom: 15px;">
3983 <div style="font-size: 13px; color: #666; margin-bottom: 5px;">Текущие товары в выдаче: ${analytics.brandRecommendations.skinphoria.currentProducts.length}</div>
3984 <div style="font-size: 13px; color: #666; margin-bottom: 5px;">Средняя позиция: ${analytics.brandRecommendations.skinphoria.avgPosition}</div>
3985 <div style="font-size: 13px; color: #666;">Средняя цена: ${analytics.brandRecommendations.skinphoria.avgPrice.toLocaleString('ru-RU')} ₽</div>
3986 </div>
3987 <details style="margin-top: 10px;" open>
3988 <summary style="cursor: pointer; font-size: 13px; color: #005bff; font-weight: 600;">Показать товары</summary>
3989 <table class="ozon-parser-analytics-table" style="margin-top: 10px;">
3990 <thead>
3991 <tr>
3992 <th>SKU</th>
3993 <th>Название</th>
3994 <th>Позиция</th>
3995 <th>Текущая цена</th>
3996 <th>⚡ Захват рынка</th>
3997 <th>✅ Оптимальная</th>
3998 <th>🔥 Агрессивная</th>
3999 </tr>
4000 </thead>
4001 <tbody>
4002 ${analytics.brandRecommendations.skinphoria.currentProducts.map(p => {
4003 const rec = p.recommendations;
4004 if (!rec) return `
4005 <tr>
4006 <td><a href="https://www.ozon.ru/product/${p.sku}" target="_blank" class="ozon-parser-sku-link">${p.sku}</a></td>
4007 <td style="max-width: 200px; white-space: normal;">${p.name || '—'}</td>
4008 <td>${p.position}</td>
4009 <td>${p.price.toLocaleString('ru-RU')} ₽</td>
4010 <td>—</td>
4011 <td>—</td>
4012 <td>—</td>
4013 </tr>
4014 `;
4015 return `
4016 <tr>
4017 <td><a href="https://www.ozon.ru/product/${p.sku}" target="_blank" class="ozon-parser-sku-link">${p.sku}</a></td>
4018 <td style="max-width: 200px; white-space: normal;">${p.name || '—'}</td>
4019 <td>${p.position}</td>
4020 <td>
4021 <div style="font-weight: 600;">${p.price.toLocaleString('ru-RU')} ₽</div>
4022 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.currentBasePrice.toLocaleString('ru-RU')} ₽</div>
4023 <div style="font-size: 11px; color: #666; margin-top: 4px;">Выручка: ${p.revenue.toLocaleString('ru-RU')} ₽</div>
4024 <div style="font-size: 11px; color: #666;">Заказы: ${p.orders.toLocaleString('ru-RU')}</div>
4025 ${rec.skuDiscount ? `
4026 <div style="font-size: 11px; color: #ff6b00; font-weight: 600; margin-top: 4px;">
4027 Скидка Ozon: ${rec.skuDiscount}%
4028 </div>
4029 ` : ''}
4030 ${rec.currentProfit !== null ? `
4031 <div style="font-size: 11px; color: #005bff; font-weight: 600; margin-top: 4px;">
4032 Прибыль: ${rec.currentProfit.toFixed(0)} ₽
4033 </div>
4034 ` : ''}
4035 </td>
4036 <td style="background: #e9ecef;">
4037 <div style="font-weight: 600; color: #6c757d;">${rec.marketCapture.toLocaleString('ru-RU')} ₽</div>
4038 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.marketCaptureBasePrice.toLocaleString('ru-RU')} ₽</div>
4039 <div style="font-size: 11px; color: ${rec.priceChange.marketCapture < 0 ? '#dc3545' : '#28a745'};">
4040 ${rec.priceChange.marketCapture > 0 ? '+' : ''}${rec.priceChange.marketCapture}%
4041 </div>
4042 ${rec.forecast && rec.forecast.marketCapture ? `
4043 <div style="font-size: 11px; color: ${rec.forecast.marketCapture.revenueChange >= 0 ? '#28a745' : '#dc3545'}; margin-top: 4px;">
4044 Выручка: ${rec.forecast.marketCapture.revenueChange > 0 ? '+' : ''}${rec.forecast.marketCapture.revenueChange}%
4045 </div>
4046 <div style="font-size: 11px; color: ${rec.forecast.marketCapture.ordersChange >= 0 ? '#28a745' : '#dc3545'};">
4047 Заказы: ${rec.forecast.marketCapture.ordersChange > 0 ? '+' : ''}${rec.forecast.marketCapture.ordersChange}%
4048 </div>
4049 ${rec.forecast.marketCapture.profit !== null ? `
4050 <div style="font-size: 11px; color: ${rec.forecast.marketCapture.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600; margin-top: 2px;">
4051 Прибыль: ${rec.forecast.marketCapture.profitChange > 0 ? '+' : ''}${rec.forecast.marketCapture.profitChange}%
4052 </div>
4053 <div style="font-size: 10px; color: #666;">
4054 ${rec.forecast.marketCapture.profit.toFixed(0)} ₽
4055 </div>
4056 ` : ''}
4057 ` : ''}
4058 </td>
4059 <td style="background: #d4edda;">
4060 <div style="font-weight: 600; color: #28a745;">${rec.aggressive.toLocaleString('ru-RU')} ₽</div>
4061 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.aggressiveBasePrice.toLocaleString('ru-RU')} ₽</div>
4062 <div style="font-size: 11px; color: ${rec.priceChange.aggressive < 0 ? '#dc3545' : '#28a745'};">
4063 ${rec.priceChange.aggressive > 0 ? '+' : ''}${rec.priceChange.aggressive}%
4064 </div>
4065 ${rec.forecast && rec.forecast.aggressive ? `
4066 <div style="font-size: 11px; color: ${rec.forecast.aggressive.revenueChange >= 0 ? '#28a745' : '#dc3545'}; margin-top: 4px;">
4067 Выручка: ${rec.forecast.aggressive.revenueChange > 0 ? '+' : ''}${rec.forecast.aggressive.revenueChange}%
4068 </div>
4069 <div style="font-size: 11px; color: ${rec.forecast.aggressive.ordersChange >= 0 ? '#28a745' : '#dc3545'};">
4070 Заказы: ${rec.forecast.aggressive.ordersChange > 0 ? '+' : ''}${rec.forecast.aggressive.ordersChange}%
4071 </div>
4072 ${rec.forecast.aggressive.profit !== null ? `
4073 <div style="font-size: 11px; color: ${rec.forecast.aggressive.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600; margin-top: 2px;">
4074 Прибыль: ${rec.forecast.aggressive.profitChange > 0 ? '+' : ''}${rec.forecast.aggressive.profitChange}%
4075 </div>
4076 <div style="font-size: 10px; color: #666;">
4077 ${rec.forecast.aggressive.profit.toFixed(0)} ₽
4078 </div>
4079 ` : ''}
4080 ` : ''}
4081 </td>
4082 <td style="background: #fff3cd;">
4083 <div style="font-weight: 600; color: #dc3545;">${rec.optimal.toLocaleString('ru-RU')} ₽</div>
4084 <div style="font-size: 10px; color: #999; margin-top: 2px;">База: ${rec.optimalBasePrice.toLocaleString('ru-RU')} ₽</div>
4085 <div style="font-size: 11px; color: ${rec.priceChange.optimal < 0 ? '#dc3545' : '#28a745'};">
4086 ${rec.priceChange.optimal > 0 ? '+' : ''}${rec.priceChange.optimal}%
4087 </div>
4088 ${rec.forecast && rec.forecast.optimal ? `
4089 <div style="font-size: 11px; color: ${rec.forecast.optimal.revenueChange >= 0 ? '#28a745' : '#dc3545'}; margin-top: 4px;">
4090 Выручка: ${rec.forecast.optimal.revenueChange > 0 ? '+' : ''}${rec.forecast.optimal.revenueChange}%
4091 </div>
4092 <div style="font-size: 11px; color: ${rec.forecast.optimal.ordersChange >= 0 ? '#28a745' : '#dc3545'};">
4093 Заказы: ${rec.forecast.optimal.ordersChange > 0 ? '+' : ''}${rec.forecast.optimal.ordersChange}%
4094 </div>
4095 ${rec.forecast.optimal.profit !== null ? `
4096 <div style="font-size: 11px; color: ${rec.forecast.optimal.profitChange >= 0 ? '#28a745' : '#dc3545'}; font-weight: 600; margin-top: 2px;">
4097 Прибыль: ${rec.forecast.optimal.profitChange > 0 ? '+' : ''}${rec.forecast.optimal.profitChange}%
4098 </div>
4099 <div style="font-size: 10px; color: #666;">
4100 ${rec.forecast.optimal.profit.toFixed(0)} ₽
4101 </div>
4102 ` : ''}
4103 ` : ''}
4104 </td>
4105 </tr>
4106 `;
4107 }).join('')}
4108 </tbody>
4109 </table>
4110 </details>
4111 </div>
4112 ` : ''}
4113 </div>
4114 ` : ''}
4115
4116 <div class="ozon-parser-analytics-section">
4117 <div class="ozon-parser-analytics-section-title">Общая статистика</div>
4118 <div class="ozon-parser-analytics-grid">
4119 <div class="ozon-parser-analytics-card">
4120 <div class="ozon-parser-analytics-card-title">Общая выручка</div>
4121 <div class="ozon-parser-analytics-card-value">${analytics.totalRevenue.toLocaleString('ru-RU')} ₽</div>
4122 </div>
4123 <div class="ozon-parser-analytics-card">
4124 <div class="ozon-parser-analytics-card-title">Всего заказов</div>
4125 <div class="ozon-parser-analytics-card-value">${analytics.totalOrders.toLocaleString('ru-RU')}</div>
4126 </div>
4127 <div class="ozon-parser-analytics-card">
4128 <div class="ozon-parser-analytics-card-title">Средний чек</div>
4129 <div class="ozon-parser-analytics-card-value">${analytics.avgPrice.toLocaleString('ru-RU')} ₽</div>
4130 </div>
4131 </div>
4132 </div>
4133
4134 <div class="ozon-parser-analytics-section">
4135 <div class="ozon-parser-analytics-section-title">Топ-5 товаров по выручке</div>
4136 <table class="ozon-parser-analytics-table">
4137 <thead>
4138 <tr>
4139 <th>Товар</th>
4140 <th>Бренд</th>
4141 <th>Цена</th>
4142 <th>Выручка</th>
4143 <th>Доля</th>
4144 </tr>
4145 </thead>
4146 <tbody>
4147 `;
4148
4149 analytics.topProducts.forEach(product => {
4150 tableHTML += `
4151 <tr>
4152 <td style="max-width: 300px; white-space: normal;">${product.name || '—'}</td>
4153 <td>${product.brand}</td>
4154 <td>${product.price.toLocaleString('ru-RU')} ₽</td>
4155 <td>${product.revenue.toLocaleString('ru-RU')} ₽</td>
4156 <td>${product.revenueShare}%</td>
4157 </tr>
4158 `;
4159 });
4160
4161 tableHTML += `
4162 </tbody>
4163 </table>
4164 </div>
4165
4166 <div class="ozon-parser-analytics-section">
4167 <div class="ozon-parser-analytics-section-title">Ценовые сегменты</div>
4168 <table class="ozon-parser-analytics-table">
4169 <thead>
4170 <tr>
4171 <th>Сегмент</th>
4172 <th>Диапазон цен</th>
4173 <th>Товаров</th>
4174 <th>Средняя цена</th>
4175 <th>Выручка</th>
4176 <th>Доля выручки</th>
4177 </tr>
4178 </thead>
4179 <tbody>
4180 `;
4181
4182 analytics.priceSegments.forEach(segment => {
4183 tableHTML += `
4184 <tr>
4185 <td>${segment.name}</td>
4186 <td>${segment.priceRange}</td>
4187 <td>${segment.count}</td>
4188 <td>${segment.avgPrice.toLocaleString('ru-RU')} ₽</td>
4189 <td>${segment.revenue.toLocaleString('ru-RU')} ₽</td>
4190 <td>
4191 <div style="display: flex; align-items: center; gap: 10px;">
4192 <div class="ozon-parser-analytics-bar" style="width: ${segment.revenueShare}%; min-width: 20px;"></div>
4193 <span>${segment.revenueShare}%</span>
4194 </div>
4195 </td>
4196 </tr>
4197 `;
4198 });
4199
4200 tableHTML += `
4201 </tbody>
4202 </table>
4203 </div>
4204 </div>
4205 `;
4206
4207 container.innerHTML = tableHTML;
4208 console.log('Ozon Product Parser: Results displayed successfully');
4209 } catch (error) {
4210 console.error('Ozon Product Parser: Error in displayResults:', error);
4211 container.innerHTML = '<p>Ошибка при отображении результатов: ' + error.message + '</p>';
4212 }
4213 }
4214
4215 // Инициализация
4216 function init() {
4217 console.log('Ozon Product Parser: Initializing...');
4218
4219 if (document.readyState === 'loading') {
4220 document.addEventListener('DOMContentLoaded', () => {
4221 addStyles();
4222 createUI();
4223 continueParsingIfActive();
4224 continueDiscountCalculationIfActive();
4225 });
4226 } else {
4227 addStyles();
4228 createUI();
4229 continueParsingIfActive();
4230 continueDiscountCalculationIfActive();
4231 }
4232 }
4233
4234 init();
4235})();