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