Ozon Opinions Collector

Собирает мнения покупателей о товарах на Ozon Seller

Size

78.4 KB

Version

1.5.16

Created

Mar 19, 2026

Updated

28 days ago

1// ==UserScript==
2// @name		Ozon Opinions Collector
3// @description		Собирает мнения покупателей о товарах на Ozon Seller
4// @version		1.5.16
5// @match		*://seller.ozon.ru/app/products*
6// ==/UserScript==
7(function() {
8    'use strict';
9
10    console.log('Ozon Opinions Collector: Расширение запущено');
11
12    // Утилита для ожидания элемента
13    function waitForElement(selector, timeout = 10000) {
14        return new Promise((resolve, reject) => {
15            if (document.querySelector(selector)) {
16                return resolve(document.querySelector(selector));
17            }
18
19            const observer = new MutationObserver(() => {
20                if (document.querySelector(selector)) {
21                    observer.disconnect();
22                    resolve(document.querySelector(selector));
23                }
24            });
25
26            observer.observe(document.body, {
27                childList: true,
28                subtree: true
29            });
30
31            setTimeout(() => {
32                observer.disconnect();
33                reject(new Error(`Element ${selector} not found within ${timeout}ms`));
34            }, timeout);
35        });
36    }
37
38    // Класс для управления расширением
39    class OpinionsCollector {
40        constructor() {
41            this.opinions = [];
42            this.isCollecting = false;
43            this.controlPanel = null;
44            this.tableModal = null;
45            this.allOpinions = new Set();
46            this.selectedFilters = new Set();
47            this.filterLogic = 'OR'; // 'AND' or 'OR'
48        }
49
50        async init() {
51            console.log('OpinionsCollector: Инициализация');
52            
53            // Ждем загрузки таблицы
54            try {
55                await waitForElement('table tbody tr', 15000);
56                console.log('OpinionsCollector: Таблица найдена');
57            } catch (e) {
58                console.error('OpinionsCollector: Таблица не найдена', e);
59                return;
60            }
61
62            // Загружаем сохраненные данные
63            await this.loadOpinions();
64
65            // Создаем панель управления
66            this.createControlPanel();
67
68            console.log('OpinionsCollector: Инициализация завершена');
69        }
70
71        async loadOpinions() {
72            try {
73                const saved = await GM.getValue('ozon_opinions', '[]');
74                this.opinions = JSON.parse(saved);
75                console.log(`OpinionsCollector: Загружено ${this.opinions.length} товаров с мнениями`);
76                
77                // Собираем все уникальные мнения
78                this.opinions.forEach(item => {
79                    if (item.opinions && Array.isArray(item.opinions)) {
80                        item.opinions.forEach(op => {
81                            this.allOpinions.add(op.text);
82                        });
83                    }
84                });
85            } catch (e) {
86                console.error('OpinionsCollector: Ошибка загрузки данных', e);
87                this.opinions = [];
88            }
89        }
90
91        async saveOpinions() {
92            try {
93                await GM.setValue('ozon_opinions', JSON.stringify(this.opinions));
94                console.log('OpinionsCollector: Данные сохранены');
95            } catch (e) {
96                console.error('OpinionsCollector: Ошибка сохранения данных', e);
97            }
98        }
99
100        createControlPanel() {
101            // Находим место для вставки панели
102            const header = document.querySelector('header') || document.querySelector('[data-widget*="header"]') || document.body;
103            
104            // Создаем контейнер для панели
105            const panel = document.createElement('div');
106            panel.id = 'opinions-control-panel';
107            panel.innerHTML = `
108                <div style="
109                    background: linear-gradient(135deg, #005bff 0%, #0041b8 100%);
110                    padding: 16px 24px;
111                    border-radius: 12px;
112                    box-shadow: 0 4px 12px rgba(0, 91, 255, 0.2);
113                    display: flex;
114                    align-items: center;
115                    gap: 16px;
116                    margin: 16px;
117                    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
118                ">
119                    <div style="
120                        color: white;
121                        font-size: 16px;
122                        font-weight: 600;
123                        flex: 1;
124                    ">
125                        📊 Сборщик мнений покупателей
126                        <span id="opinions-counter" style="
127                            display: inline-block;
128                            background: rgba(255, 255, 255, 0.2);
129                            padding: 4px 12px;
130                            border-radius: 20px;
131                            font-size: 14px;
132                            margin-left: 12px;
133                        ">
134                            Собрано: ${this.opinions.length} товаров
135                        </span>
136                    </div>
137                    <button id="opinions-view-btn" style="
138                        background: white;
139                        color: #005bff;
140                        border: none;
141                        padding: 10px 24px;
142                        border-radius: 8px;
143                        font-size: 14px;
144                        font-weight: 600;
145                        cursor: pointer;
146                        transition: all 0.2s;
147                        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
148                    " onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 12px rgba(0, 0, 0, 0.15)'" onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 2px 8px rgba(0, 0, 0, 0.1)'">
149                        👁️ Посмотреть
150                    </button>
151                    <button id="opinions-download-btn" style="
152                        background: #28a745;
153                        color: white;
154                        border: none;
155                        padding: 10px 24px;
156                        border-radius: 8px;
157                        font-size: 14px;
158                        font-weight: 600;
159                        cursor: pointer;
160                        transition: all 0.2s;
161                        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
162                    " onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 12px rgba(0, 0, 0, 0.15)'" onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 2px 8px rgba(0, 0, 0, 0.1)'">
163                        📥 Скачать CSV
164                    </button>
165                    <button id="opinions-import-btn" style="
166                        background: #17a2b8;
167                        color: white;
168                        border: none;
169                        padding: 10px 24px;
170                        border-radius: 8px;
171                        font-size: 14px;
172                        font-weight: 600;
173                        cursor: pointer;
174                        transition: all 0.2s;
175                        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
176                    " onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 12px rgba(0, 0, 0, 0.15)'" onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 2px 8px rgba(0, 0, 0, 0.1)'">
177                        📤 Импорт CSV
178                    </button>
179                    <button id="opinions-collect-btn" style="
180                        background: rgba(255, 255, 255, 0.2);
181                        color: white;
182                        border: 2px solid white;
183                        padding: 10px 24px;
184                        border-radius: 8px;
185                        font-size: 14px;
186                        font-weight: 600;
187                        cursor: pointer;
188                        transition: all 0.2s;
189                    " onmouseover="this.style.background='white'; this.style.color='#005bff'" onmouseout="this.style.background='rgba(255, 255, 255, 0.2)'; this.style.color='white'">
190                        🔄 Собрать
191                    </button>
192                </div>
193            `;
194
195            // Вставляем панель в начало страницы
196            if (header.tagName === 'HEADER') {
197                header.after(panel);
198            } else {
199                document.body.insertBefore(panel, document.body.firstChild);
200            }
201
202            this.controlPanel = panel;
203
204            // Добавляем обработчики событий
205            document.getElementById('opinions-view-btn').addEventListener('click', () => this.showOpinionsTable());
206            document.getElementById('opinions-download-btn').addEventListener('click', () => this.downloadCSV());
207            document.getElementById('opinions-import-btn').addEventListener('click', () => this.importCSV());
208            document.getElementById('opinions-collect-btn').addEventListener('click', () => this.startCollecting());
209
210            console.log('OpinionsCollector: Панель управления создана');
211        }
212
213        async startCollecting() {
214            if (this.isCollecting) {
215                console.log('OpinionsCollector: Сбор уже идет');
216                return;
217            }
218
219            this.isCollecting = true;
220            const collectBtn = document.getElementById('opinions-collect-btn');
221            const originalText = collectBtn.innerHTML;
222            collectBtn.innerHTML = '⏳ Собираю...';
223            collectBtn.disabled = true;
224
225            console.log('OpinionsCollector: Начинаю сбор мнений');
226
227            try {
228                // Очищаем старые данные
229                this.opinions = [];
230                this.allOpinions.clear();
231
232                // Получаем количество страниц
233                const pagination = document.querySelector('#pagination');
234                let totalPages = 1;
235                if (pagination) {
236                    // Ищем последнюю цифру перед стрелкой - это общее количество страниц
237                    const pageButtons = pagination.querySelectorAll('button[type="button"]');
238                    // Предпоследняя кнопка содержит номер последней страницы
239                    if (pageButtons.length >= 2) {
240                        const lastPageButton = pageButtons[pageButtons.length - 2];
241                        totalPages = parseInt(lastPageButton.textContent.trim()) || 1;
242                    }
243                }
244
245                console.log(`OpinionsCollector: Найдено ${totalPages} страниц`);
246
247                // Создаем прогресс-бар
248                this.showProgressBar(0, totalPages);
249
250                // Собираем данные со всех страниц
251                for (let page = 1; page <= totalPages; page++) {
252                    console.log(`OpinionsCollector: Обработка страницы ${page}/${totalPages}`);
253                    
254                    // Если не первая страница, переходим на нее
255                    if (page > 1) {
256                        await this.goToPage(page);
257                        
258                        // Ждем загрузки новой страницы - ждем пока таблица обновится
259                        console.log('OpinionsCollector: Ожидание загрузки новой страницы...');
260                        await this.sleep(2000); // Начальная задержка
261                        
262                        // Ждем пока таблица обновится (проверяем что данные изменились)
263                        await this.waitForPageLoad();
264                    }
265
266                    // Собираем мнения с текущей страницы
267                    await this.collectCurrentPage();
268
269                    // Обновляем прогресс
270                    this.updateProgressBar(page, totalPages);
271                }
272
273                // Сохраняем данные
274                await this.saveOpinions();
275
276                // Обновляем счетчик
277                this.updateCounter();
278
279                // Показываем таблицу
280                this.showOpinionsTable();
281
282                console.log('OpinionsCollector: Сбор завершен');
283                collectBtn.innerHTML = '✅ Готово!';
284                setTimeout(() => {
285                    collectBtn.innerHTML = originalText;
286                    collectBtn.disabled = false;
287                }, 2000);
288
289            } catch (e) {
290                console.error('OpinionsCollector: Ошибка при сборе', e);
291                collectBtn.innerHTML = '❌ Ошибка';
292                setTimeout(() => {
293                    collectBtn.innerHTML = originalText;
294                    collectBtn.disabled = false;
295                }, 2000);
296            } finally {
297                this.isCollecting = false;
298                this.hideProgressBar();
299            }
300        }
301
302        async goToPage(pageNumber) {
303            // Используем стрелку "вперед" для перехода на следующую страницу
304            const pagination = document.querySelector('#pagination');
305            if (!pagination) {
306                console.log('OpinionsCollector: Пагинация не найдена');
307                return;
308            }
309
310            console.log('OpinionsCollector: Пагинация найдена, ищем кнопку "вперед"');
311
312            // Находим все кнопки в пагинации
313            const allButtons = pagination.querySelectorAll('button');
314            // Последняя кнопка - это стрелка "вперед"
315            const nextButton = allButtons[allButtons.length - 1];
316            
317            console.log('OpinionsCollector: Найденная кнопка:', nextButton);
318            console.log('OpinionsCollector: Кнопка disabled?', nextButton ? nextButton.disabled : 'кнопка не найдена');
319            console.log('OpinionsCollector: Текст кнопки:', nextButton ? nextButton.textContent : 'кнопка не найдена');
320            
321            if (nextButton && !nextButton.disabled) {
322                console.log('OpinionsCollector: Кликаю на кнопку "вперед"');
323                nextButton.click();
324                console.log('OpinionsCollector: Клик выполнен, переход на следующую страницу');
325            } else {
326                console.log('OpinionsCollector: Кнопка "вперед" не найдена или недоступна');
327            }
328        }
329
330        async waitForPageLoad() {
331            // Ждем пока таблица обновится после перехода на новую страницу
332            return new Promise((resolve) => {
333                console.log('OpinionsCollector: Начинаю ожидание обновления таблицы...');
334                
335                // Сохраняем текущий первый SKU для сравнения
336                const currentFirstRow = document.querySelector('tbody tr');
337                const currentFirstSKU = currentFirstRow ? 
338                    currentFirstRow.querySelectorAll('td')[3]?.textContent.trim() : null;
339                
340                console.log('OpinionsCollector: Текущий первый SKU:', currentFirstSKU);
341                
342                const tbody = document.querySelector('tbody');
343                if (!tbody) {
344                    console.log('OpinionsCollector: tbody не найден');
345                    resolve();
346                    return;
347                }
348                
349                let changeDetected = false;
350                let timeout;
351                
352                // Создаем MutationObserver для отслеживания изменений в таблице
353                const observer = new MutationObserver((mutations) => {
354                    if (changeDetected) return;
355                    
356                    // Проверяем что данные действительно изменились
357                    const newFirstRow = document.querySelector('tbody tr');
358                    const newFirstSKU = newFirstRow ? 
359                        newFirstRow.querySelectorAll('td')[3]?.textContent.trim() : null;
360                    
361                    console.log('OpinionsCollector: Новый первый SKU:', newFirstSKU);
362                    
363                    // Если SKU изменился - значит таблица обновилась
364                    if (newFirstSKU && newFirstSKU !== currentFirstSKU) {
365                        console.log('OpinionsCollector: Таблица обновлена! SKU изменился с', currentFirstSKU, 'на', newFirstSKU);
366                        changeDetected = true;
367                        clearTimeout(timeout);
368                        observer.disconnect();
369                        
370                        // Даем еще немного времени для полной загрузки
371                        setTimeout(() => resolve(), 500);
372                    }
373                });
374                
375                // Наблюдаем за изменениями в tbody
376                observer.observe(tbody, {
377                    childList: true,
378                    subtree: true,
379                    characterData: true
380                });
381                
382                // Таймаут на случай если изменения не произойдут
383                timeout = setTimeout(() => {
384                    console.log('OpinionsCollector: Таймаут ожидания обновления таблицы');
385                    observer.disconnect();
386                    resolve();
387                }, 10000); // 10 секунд максимум
388            });
389        }
390
391        async collectCurrentPage() {
392            // Получаем количество товаров на странице из элемента dn0-a8f
393            const itemsPerPageElement = document.querySelector('.dn0-a8f');
394            let itemsPerPage = 20; // По умолчанию
395            
396            if (itemsPerPageElement) {
397                const match = itemsPerPageElement.textContent.match(/(\d+)/);
398                if (match) {
399                    itemsPerPage = parseInt(match[1]);
400                    console.log(`OpinionsCollector: Товаров на странице: ${itemsPerPage}`);
401                }
402            }
403            
404            const rows = document.querySelectorAll('tbody tr');
405            console.log(`OpinionsCollector: Найдено ${rows.length} строк на странице`);
406
407            let processedCount = 0;
408            
409            for (let i = 0; i < rows.length && processedCount < itemsPerPage; i++) {
410                const row = rows[i];
411                const cells = row.querySelectorAll('td');
412                
413                if (cells.length < 12) continue;
414
415                // Получаем данные товара
416                const sku = cells[3] ? cells[3].textContent.trim().split('SKU')[1]?.trim() : '';
417                const name = cells[4] ? cells[4].textContent.trim() : '';
418                const reviewCountCell = cells[11];
419                const reviewCount = reviewCountCell ? reviewCountCell.textContent.trim() : '0';
420
421                processedCount++;
422
423                // Пропускаем товары без отзывов
424                if (reviewCount === '0' || !reviewCount) {
425                    continue;
426                }
427
428                console.log(`OpinionsCollector: Обработка товара ${processedCount}: SKU=${sku}, Отзывов=${reviewCount}`);
429
430                // Наводим курсор на ячейку с отзывами
431                const opinions = await this.extractOpinionsFromCell(reviewCountCell);
432
433                if (opinions && opinions.length > 0) {
434                    this.opinions.push({
435                        sku: sku,
436                        name: name,
437                        reviewCount: parseInt(reviewCount) || 0,
438                        opinions: opinions
439                    });
440
441                    // Добавляем мнения в общий список
442                    opinions.forEach(op => {
443                        this.allOpinions.add(op.text);
444                    });
445
446                    console.log(`OpinionsCollector: Найдено ${opinions.length} мнений для товара ${sku}`);
447                }
448
449                // Небольшая задержка между товарами
450                await this.sleep(200);
451            }
452            
453            console.log(`OpinionsCollector: Обработано ${processedCount} товаров на странице`);
454        }
455
456        async extractOpinionsFromCell(cell) {
457            return new Promise((resolve) => {
458                // Ищем элемент с классом ct1110-a - это триггер для тултипа
459                const triggerElement = cell.querySelector('.ct1110-a');
460                
461                if (!triggerElement) {
462                    console.log('OpinionsCollector: Триггер элемент не найден');
463                    resolve([]);
464                    return;
465                }
466                
467                console.log('OpinionsCollector: Наведение курсора на элемент', triggerElement.textContent.trim().substring(0, 50));
468                
469                // Наводим курсор на триггер элемент
470                const mouseEnterEvent = new MouseEvent('mouseenter', {
471                    view: window,
472                    bubbles: true,
473                    cancelable: true
474                });
475                triggerElement.dispatchEvent(mouseEnterEvent);
476                
477                const mouseOverEvent = new MouseEvent('mouseover', {
478                    view: window,
479                    bubbles: true,
480                    cancelable: true
481                });
482                triggerElement.dispatchEvent(mouseOverEvent);
483
484                // Ждем появления тултипа
485                setTimeout(() => {
486                    const tooltip = document.querySelector('[data-tippy-root] .tippy-content');
487                    
488                    if (!tooltip) {
489                        console.log('OpinionsCollector: Тултип не найден');
490                        
491                        // Убираем курсор
492                        const mouseLeaveEvent = new MouseEvent('mouseleave', {
493                            view: window,
494                            bubbles: true,
495                            cancelable: true
496                        });
497                        triggerElement.dispatchEvent(mouseLeaveEvent);
498                        
499                        resolve([]);
500                        return;
501                    }
502
503                    console.log('OpinionsCollector: Тултип найден');
504
505                    // Ищем кнопку "Посмотреть ещё"
506                    const moreButton = tooltip.querySelector('button');
507                    if (moreButton && moreButton.textContent.includes('Посмотреть ещё')) {
508                        console.log('OpinionsCollector: Нажимаю кнопку "Посмотреть ещё"');
509                        moreButton.click();
510                        
511                        // Ждем открытия модального окна
512                        setTimeout(() => {
513                            const modal = document.querySelector('.sc1110-a');
514                            if (modal) {
515                                const opinions = this.parseOpinionsFromModal(modal);
516                                console.log('OpinionsCollector: Извлечено мнений из модального окна:', opinions.length);
517                                
518                                // Закрываем модальное окно
519                                const closeButton = modal.querySelector('button[aria-label="Закрыть"]') || 
520                                                   modal.querySelector('.sc1110-a3 button') ||
521                                                   document.querySelector('[class*="close"]');
522                                if (closeButton) {
523                                    closeButton.click();
524                                }
525                                
526                                // Убираем курсор
527                                const mouseLeaveEvent = new MouseEvent('mouseleave', {
528                                    view: window,
529                                    bubbles: true,
530                                    cancelable: true
531                                });
532                                triggerElement.dispatchEvent(mouseLeaveEvent);
533                                
534                                resolve(opinions);
535                            } else {
536                                // Если модальное окно не открылось, парсим из тултипа
537                                const opinions = this.parseOpinionsFromTooltip(tooltip);
538                                console.log('OpinionsCollector: Извлечено мнений из тултипа:', opinions.length);
539                                
540                                // Убираем курсор
541                                const mouseLeaveEvent = new MouseEvent('mouseleave', {
542                                    view: window,
543                                    bubbles: true,
544                                    cancelable: true
545                                });
546                                triggerElement.dispatchEvent(mouseLeaveEvent);
547                                
548                                resolve(opinions);
549                            }
550                        }, 2000);
551                    } else {
552                        const opinions = this.parseOpinionsFromTooltip(tooltip);
553                        console.log('OpinionsCollector: Извлечено мнений:', opinions.length);
554                        
555                        // Убираем курсор
556                        const mouseLeaveEvent = new MouseEvent('mouseleave', {
557                            view: window,
558                            bubbles: true,
559                            cancelable: true
560                        });
561                        triggerElement.dispatchEvent(mouseLeaveEvent);
562                        
563                        resolve(opinions);
564                    }
565                }, 2000);
566            });
567        }
568
569        parseOpinionsFromModal(modal) {
570            const opinions = [];
571            const rows = modal.querySelectorAll('tbody tr');
572            
573            console.log('OpinionsCollector: Найдено строк в модальном окне:', rows.length);
574            
575            rows.forEach(row => {
576                const iconContainer = row.querySelector('.dn0-u8');
577                const textElement = row.querySelector('.dn0-u5');
578                const percentElement = row.querySelector('.dn0-u4');
579                
580                if (!textElement) return;
581                
582                const opinionText = textElement.textContent.trim();
583                
584                // Определяем тональность по классу SVG иконки
585                let sentiment = 'positive';
586                if (iconContainer) {
587                    const svg = iconContainer.querySelector('svg');
588                    if (svg) {
589                        const svgClass = svg.className.baseVal || svg.getAttribute('class');
590                        if (svgClass.includes('dn0-u9')) {
591                            sentiment = 'negative'; // Грустный смайлик
592                        } else if (svgClass.includes('dn0-v0')) {
593                            sentiment = 'neutral'; // Нейтральный смайлик
594                        } else if (svgClass.includes('dn0-v')) {
595                            sentiment = 'positive'; // Улыбающийся смайлик
596                        }
597                    }
598                }
599                
600                // Получаем процент
601                let percentage = 0;
602                if (percentElement) {
603                    const percentText = percentElement.textContent.trim();
604                    if (percentText.includes('<')) {
605                        percentage = 0.5;
606                    } else {
607                        percentage = parseFloat(percentText.replace('%', ''));
608                    }
609                }
610                
611                opinions.push({
612                    text: opinionText,
613                    percentage: percentage,
614                    sentiment: sentiment
615                });
616            });
617            
618            return opinions;
619        }
620
621        parseOpinionsFromTooltip(tooltip) {
622            const opinions = [];
623            const listItems = tooltip.querySelectorAll('ul li p');
624            
625            listItems.forEach(item => {
626                const text = item.textContent.trim();
627                
628                // Пропускаем элемент "И ещё X"
629                if (text.startsWith('И ещё')) {
630                    return;
631                }
632                
633                const match = text.match(/(.+?)\s*—\s*(.+?)$/);
634                
635                if (match) {
636                    const opinionText = match[1].trim();
637                    const percentageText = match[2].trim();
638                    
639                    // Парсим процент (может быть "6%" или "< 1%")
640                    let percentage = 0;
641                    if (percentageText.includes('<')) {
642                        percentage = 0.5; // Для "< 1%" ставим 0.5
643                    } else {
644                        percentage = parseFloat(percentageText.replace('%', ''));
645                    }
646                    
647                    opinions.push({
648                        text: opinionText,
649                        percentage: percentage
650                    });
651                }
652            });
653
654            return opinions;
655        }
656
657        showProgressBar(current, total) {
658            const progressBar = document.createElement('div');
659            progressBar.id = 'opinions-progress-bar';
660            progressBar.innerHTML = `
661                <div style="
662                    position: fixed;
663                    top: 50%;
664                    left: 50%;
665                    transform: translate(-50%, -50%);
666                    background: white;
667                    padding: 32px;
668                    border-radius: 16px;
669                    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
670                    z-index: 10000;
671                    min-width: 400px;
672                    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
673                ">
674                    <div style="font-size: 18px; font-weight: 600; margin-bottom: 16px; color: #333;">
675                        🔄 Сбор мнений...
676                    </div>
677                    <div style="
678                        background: #f0f0f0;
679                        height: 8px;
680                        border-radius: 4px;
681                        overflow: hidden;
682                        margin-bottom: 12px;
683                    ">
684                        <div id="progress-bar-fill" style="
685                            background: linear-gradient(90deg, #005bff 0%, #0041b8 100%);
686                            height: 100%;
687                            width: 0%;
688                            transition: width 0.3s;
689                        "></div>
690                    </div>
691                    <div id="progress-text" style="
692                        font-size: 14px;
693                        color: #666;
694                        text-align: center;
695                    ">
696                        Страница ${current} из ${total}
697                    </div>
698                </div>
699                <div style="
700                    position: fixed;
701                    top: 0;
702                    left: 0;
703                    width: 100%;
704                    height: 100%;
705                    background: rgba(0, 0, 0, 0.5);
706                    z-index: 9999;
707                "></div>
708            `;
709            document.body.appendChild(progressBar);
710        }
711
712        updateProgressBar(current, total) {
713            const fill = document.getElementById('progress-bar-fill');
714            const text = document.getElementById('progress-text');
715            
716            if (fill && text) {
717                const percentage = (current / total) * 100;
718                fill.style.width = `${percentage}%`;
719                text.textContent = `Страница ${current} из ${total} • Собрано товаров: ${this.opinions.length}`;
720            }
721        }
722
723        hideProgressBar() {
724            const progressBar = document.getElementById('opinions-progress-bar');
725            if (progressBar) {
726                progressBar.remove();
727            }
728        }
729
730        updateCounter() {
731            const counter = document.getElementById('opinions-counter');
732            if (counter) {
733                counter.textContent = `Собрано: ${this.opinions.length} товаров`;
734            }
735        }
736
737        showOpinionsTable() {
738            // Удаляем старую таблицу если есть
739            if (this.tableModal) {
740                this.tableModal.remove();
741            }
742
743            // Создаем модальное окно с таблицей
744            const modal = document.createElement('div');
745            modal.id = 'opinions-table-modal';
746            
747            // Получаем все уникальные мнения для фильтров
748            const allOpinionsArray = Array.from(this.allOpinions).sort();
749            
750            // Создаем карту мнений с их тональностью
751            const opinionSentimentMap = new Map();
752            this.opinions.forEach(item => {
753                if (item.opinions && Array.isArray(item.opinions)) {
754                    item.opinions.forEach(op => {
755                        if (!opinionSentimentMap.has(op.text)) {
756                            opinionSentimentMap.set(op.text, op.sentiment || 'positive');
757                        }
758                    });
759                }
760            });
761
762            modal.innerHTML = `
763                <div style="
764                    position: fixed;
765                    top: 0;
766                    left: 0;
767                    width: 100%;
768                    height: 100%;
769                    background: rgba(0, 0, 0, 0.5);
770                    z-index: 9998;
771                " id="modal-backdrop"></div>
772                <div style="
773                    position: fixed;
774                    top: 50%;
775                    left: 50%;
776                    transform: translate(-50%, -50%);
777                    background: white;
778                    border-radius: 16px;
779                    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
780                    z-index: 9999;
781                    width: 90%;
782                    max-width: 1400px;
783                    max-height: 90vh;
784                    display: flex;
785                    flex-direction: column;
786                    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
787                ">
788                    <!-- Заголовок -->
789                    <div style="
790                        padding: 24px;
791                        border-bottom: 1px solid #e0e0e0;
792                        display: flex;
793                        justify-content: space-between;
794                        align-items: center;
795                    ">
796                        <div>
797                            <h2 style="margin: 0; font-size: 24px; color: #333;">📊 Мнения покупателей</h2>
798                            <p style="margin: 8px 0 0 0; color: #666; font-size: 14px;">Всего товаров: ${this.opinions.length}</p>
799                        </div>
800                        <button id="close-modal-btn" style="
801                            background: none;
802                            border: none;
803                            font-size: 32px;
804                            cursor: pointer;
805                            color: #999;
806                            line-height: 1;
807                            padding: 0;
808                            width: 40px;
809                            height: 40px;
810                        ">×</button>
811                    </div>
812
813                    <!-- Поиск -->
814                    <div style="
815                        padding: 16px 24px;
816                        border-bottom: 1px solid #e0e0e0;
817                        background: #f8f9fa;
818                    ">
819                        <div style="display: flex; gap: 12px; align-items: center;">
820                            <div style="flex: 1;">
821                                <input 
822                                    type="text" 
823                                    id="search-input" 
824                                    placeholder="🔍 Поиск по SKU или названию товара..."
825                                    style="
826                                        width: 100%;
827                                        padding: 10px 16px;
828                                        border: 2px solid #e0e0e0;
829                                        border-radius: 8px;
830                                        font-size: 14px;
831                                        transition: all 0.2s;
832                                        outline: none;
833                                    "
834                                    onfocus="this.style.borderColor='#005bff'"
835                                    onblur="this.style.borderColor='#e0e0e0'"
836                                />
837                            </div>
838                            <div style="flex: 1;">
839                                <input 
840                                    type="text" 
841                                    id="filter-search-input" 
842                                    placeholder="🔍 Поиск по мнениям..."
843                                    style="
844                                        width: 100%;
845                                        padding: 10px 16px;
846                                        border: 2px solid #e0e0e0;
847                                        border-radius: 8px;
848                                        font-size: 14px;
849                                        transition: all 0.2s;
850                                        outline: none;
851                                    "
852                                    onfocus="this.style.borderColor='#005bff'"
853                                    onblur="this.style.borderColor='#e0e0e0'"
854                                />
855                            </div>
856                        </div>
857                    </div>
858
859                    <!-- Фильтры -->
860                    <div style="
861                        padding: 16px 24px;
862                        border-bottom: 1px solid #e0e0e0;
863                        background: #f8f9fa;
864                    ">
865                        <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
866                            <div style="display: flex; gap: 12px; align-items: center;">
867                                <button id="toggle-filters-btn" style="
868                                    background: #6c757d;
869                                    color: white;
870                                    border: none;
871                                    padding: 8px 16px;
872                                    border-radius: 8px;
873                                    font-size: 13px;
874                                    font-weight: 600;
875                                    cursor: pointer;
876                                    transition: all 0.2s;
877                                    display: flex;
878                                    align-items: center;
879                                    gap: 6px;
880                                " onmouseover="this.style.background='#5a6268'" onmouseout="this.style.background='#6c757d'">
881                                    <span id="filter-toggle-icon"></span>
882                                    Фильтры по мнениям
883                                </button>
884                                <div style="font-size: 12px; color: #666;">
885                                    🟢 позитивные • 🔴 негативные
886                                </div>
887                            </div>
888                            <div style="display: flex; gap: 8px; align-items: center;">
889                                <span style="font-size: 13px; color: #666;">Мнения:</span>
890                                <select id="sentiment-filter" style="
891                                    padding: 6px 12px;
892                                    border-radius: 8px;
893                                    border: 1px solid #ddd;
894                                    font-size: 13px;
895                                    cursor: pointer;
896                                    background: white;
897                                ">
898                                    <option value="all">Все мнения</option>
899                                    <option value="positive">Только позитивные</option>
900                                    <option value="negative">Только негативные</option>
901                                </select>
902                                <span style="font-size: 13px; color: #666;">Фильтры:</span>
903                                <select id="filter-sentiment" style="
904                                    padding: 6px 12px;
905                                    border-radius: 8px;
906                                    border: 1px solid #ddd;
907                                    font-size: 13px;
908                                    cursor: pointer;
909                                    background: white;
910                                ">
911                                    <option value="all">Все фильтры</option>
912                                    <option value="positive">Только позитивные</option>
913                                    <option value="negative">Только негативные</option>
914                                </select>
915                                <span style="font-size: 13px; color: #666;">Логика:</span>
916                                <button id="logic-toggle-btn" style="
917                                    background: #005bff;
918                                    color: white;
919                                    border: none;
920                                    padding: 6px 16px;
921                                    border-radius: 20px;
922                                    font-size: 13px;
923                                    font-weight: 600;
924                                    cursor: pointer;
925                                    transition: all 0.2s;
926                                ">${this.filterLogic}</button>
927                                <button id="clear-filters-btn" style="
928                                    background: #dc3545;
929                                    color: white;
930                                    border: none;
931                                    padding: 6px 16px;
932                                    border-radius: 20px;
933                                    font-size: 13px;
934                                    font-weight: 600;
935                                    cursor: pointer;
936                                    transition: all 0.2s;
937                                    ${this.selectedFilters.size === 0 ? 'opacity: 0.5; cursor: not-allowed;' : ''}
938                                ">Сбросить фильтры</button>
939                            </div>
940                        </div>
941                        
942                        <!-- Выпадающая панель с фильтрами -->
943                        <div id="filters-panel" style="
944                            max-height: 0;
945                            overflow: hidden;
946                            transition: max-height 0.3s ease-out;
947                        ">
948                            <div style="padding-top: 12px;">
949                                <div style="display: flex; flex-wrap: wrap; gap: 8px; max-height: 200px; overflow-y: auto; padding: 8px;" id="opinion-filters">
950                                    ${allOpinionsArray.map(opinion => {
951        const isSelected = this.selectedFilters.has(opinion);
952        const sentiment = opinionSentimentMap.get(opinion) || 'positive';
953        const bgColor = sentiment === 'positive' ? '#28a745' : '#dc3545';
954        const selectedBgColor = sentiment === 'positive' ? '#1e7e34' : '#bd2130';
955                                        
956        return `
957                                            <button class="filter-btn" data-filter="${opinion}" style="
958                                                background: ${isSelected ? selectedBgColor : bgColor};
959                                                color: white;
960                                                border: none;
961                                                padding: 8px 16px;
962                                                border-radius: 20px;
963                                                font-size: 13px;
964                                                cursor: pointer;
965                                                transition: all 0.2s;
966                                                font-weight: ${isSelected ? '600' : '400'};
967                                                opacity: ${isSelected ? '1' : '0.85'};
968                                                box-shadow: ${isSelected ? '0 2px 8px rgba(0, 0, 0, 0.2)' : 'none'};
969                                            " onmouseover="this.style.opacity='1'; this.style.transform='translateY(-1px)'" onmouseout="this.style.opacity='${isSelected ? '1' : '0.85'}'; this.style.transform='translateY(0)'">${opinion}</button>
970                                        `;
971    }).join('')}
972                                </div>
973                            </div>
974                        </div>
975                        
976                        ${this.selectedFilters.size > 0 ? `
977                            <div style="margin-top: 12px; padding: 8px 12px; background: #e7f3ff; border-radius: 8px; font-size: 13px; color: #005bff;">
978                                <strong>Активные фильтры (${this.selectedFilters.size}):</strong> 
979                                ${Array.from(this.selectedFilters).join(this.filterLogic === 'AND' ? ' И ' : ' ИЛИ ')}
980                            </div>
981                        ` : ''}
982                    </div>
983
984                    <!-- Таблица -->
985                    <div style="
986                        flex: 1;
987                        overflow: auto;
988                        padding: 24px;
989                    ">
990                        <table id="opinions-data-table" style="
991                            width: 100%;
992                            border-collapse: collapse;
993                        ">
994                            <thead>
995                                <tr style="background: #f8f9fa; border-bottom: 2px solid #e0e0e0;">
996                                    <th id="sort-sku" style="padding: 12px; text-align: left; font-weight: 600; color: #333; width: 120px; cursor: pointer; user-select: none;" title="Нажмите для сортировки">
997                                        SKU <span style="font-size: 10px;"></span>
998                                    </th>
999                                    <th id="sort-name" style="padding: 12px; text-align: left; font-weight: 600; color: #333; cursor: pointer; user-select: none;" title="Нажмите для сортировки">
1000                                        Название <span style="font-size: 10px;"></span>
1001                                    </th>
1002                                    <th id="sort-reviews" style="padding: 12px; text-align: center; font-weight: 600; color: #333; width: 100px; cursor: pointer; user-select: none;" title="Нажмите для сортировки">
1003                                        Отзывов <span style="font-size: 10px;"></span>
1004                                    </th>
1005                                    <th style="padding: 12px; text-align: center; font-weight: 600; color: #333; width: 150px;">
1006                                        <div style="display: flex; flex-direction: column; gap: 4px; align-items: center;">
1007                                            <div id="sort-positive" style="cursor: pointer; user-select: none;" title="Нажмите для сортировки">
1008                                                🟢 Позитивные <span style="font-size: 10px;"></span>
1009                                            </div>
1010                                            <div id="sort-negative" style="cursor: pointer; user-select: none;" title="Нажмите для сортировки">
1011                                                🔴 Негативные <span style="font-size: 10px;"></span>
1012                                            </div>
1013                                        </div>
1014                                    </th>
1015                                    <th style="padding: 12px; text-align: left; font-weight: 600; color: #333; width: 400px;">Мнения</th>
1016                                </tr>
1017                            </thead>
1018                            <tbody id="opinions-table-body">
1019                                ${this.renderTableRows()}
1020                            </tbody>
1021                        </table>
1022                    </div>
1023                </div>
1024            `;
1025
1026            document.body.appendChild(modal);
1027            this.tableModal = modal;
1028
1029            // Обработчики событий
1030            document.getElementById('close-modal-btn').addEventListener('click', () => this.closeTable());
1031            document.getElementById('opinions-backdrop').addEventListener('click', () => this.closeTable());
1032
1033            // Обработчик поиска по SKU/названию
1034            document.getElementById('search-input').addEventListener('input', (e) => {
1035                this.searchQuery = e.target.value.toLowerCase();
1036                this.updateTableContent();
1037            });
1038
1039            // Обработчик поиска по фильтрам
1040            document.getElementById('filter-search-input').addEventListener('input', (e) => {
1041                this.filterSearchQuery = e.target.value.toLowerCase();
1042                this.updateFilterButtons();
1043            });
1044
1045            // Обработчик переключения видимости фильтров
1046            document.getElementById('toggle-filters-btn').addEventListener('click', () => {
1047                const filtersPanel = document.getElementById('filters-panel');
1048                const icon = document.getElementById('filter-toggle-icon');
1049                
1050                if (filtersPanel.style.maxHeight === '0px' || filtersPanel.style.maxHeight === '') {
1051                    filtersPanel.style.maxHeight = '250px';
1052                    icon.textContent = '▲';
1053                } else {
1054                    filtersPanel.style.maxHeight = '0px';
1055                    icon.textContent = '▼';
1056                }
1057            });
1058
1059            // Обработчик фильтра по тональности
1060            document.getElementById('sentiment-filter').addEventListener('change', (e) => {
1061                this.sentimentFilter = e.target.value;
1062                this.updateTableContent();
1063            });
1064
1065            // Обработчик фильтра по тональности кнопок фильтров
1066            document.getElementById('filter-sentiment').addEventListener('change', (e) => {
1067                this.filterSentiment = e.target.value;
1068                this.updateFilterButtons();
1069            });
1070
1071            // Обработчик переключения логики
1072            document.getElementById('logic-toggle-btn').addEventListener('click', () => {
1073                this.filterLogic = this.filterLogic === 'AND' ? 'OR' : 'AND';
1074                document.getElementById('logic-toggle-btn').textContent = this.filterLogic;
1075                this.updateTableContent();
1076            });
1077
1078            // Обработчик сброса фильтров
1079            document.getElementById('clear-filters-btn').addEventListener('click', () => {
1080                if (this.selectedFilters.size > 0) {
1081                    this.selectedFilters.clear();
1082                    this.updateTableContent();
1083                    this.updateFilterButtons();
1084                }
1085            });
1086
1087            // Обработчики сортировки
1088            document.getElementById('sort-sku').addEventListener('click', () => this.sortTable('sku'));
1089            document.getElementById('sort-name').addEventListener('click', () => this.sortTable('name'));
1090            document.getElementById('sort-reviews').addEventListener('click', () => this.sortTable('reviews'));
1091            document.getElementById('sort-positive').addEventListener('click', () => this.sortTable('positive'));
1092            document.getElementById('sort-negative').addEventListener('click', () => this.sortTable('negative'));
1093
1094            // Обработчики фильтров
1095            const filterButtons = modal.querySelectorAll('.filter-btn');
1096            filterButtons.forEach(btn => {
1097                btn.addEventListener('click', (e) => {
1098                    const filter = e.target.getAttribute('data-filter');
1099                    
1100                    // Переключаем выбор фильтра
1101                    if (this.selectedFilters.has(filter)) {
1102                        this.selectedFilters.delete(filter);
1103                    } else {
1104                        this.selectedFilters.add(filter);
1105                    }
1106
1107                    // Обновляем только таблицу и кнопки фильтров, не перерисовываем всё
1108                    this.updateTableContent();
1109                    this.updateFilterButtons();
1110                });
1111            });
1112
1113            console.log('OpinionsCollector: Таблица отображена');
1114        }
1115
1116        sortTable(column) {
1117            // Переключаем направление сортировки
1118            if (this.sortColumn === column) {
1119                this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
1120            } else {
1121                this.sortColumn = column;
1122                this.sortDirection = 'desc'; // По умолчанию сортируем по убыванию
1123            }
1124            
1125            this.updateTableContent();
1126        }
1127
1128        updateFilterButtons() {
1129            const filterSearchQuery = this.filterSearchQuery || '';
1130            const filterSentiment = this.filterSentiment || 'all';
1131            const filterButtons = document.querySelectorAll('.filter-btn');
1132            
1133            // Получаем карту тональностей для всех мнений
1134            const opinionSentimentMap = new Map();
1135            this.opinions.forEach(item => {
1136                if (item.opinions && Array.isArray(item.opinions)) {
1137                    item.opinions.forEach(op => {
1138                        if (!opinionSentimentMap.has(op.text)) {
1139                            opinionSentimentMap.set(op.text, op.sentiment || 'positive');
1140                        }
1141                    });
1142                }
1143            });
1144            
1145            filterButtons.forEach(btn => {
1146                const filterText = btn.getAttribute('data-filter');
1147                const filterTextLower = filterText.toLowerCase();
1148                const isSelected = this.selectedFilters.has(filterText);
1149                const sentiment = opinionSentimentMap.get(filterText) || 'positive';
1150                
1151                // Проверяем соответствие поисковому запросу
1152                const matchesSearch = filterSearchQuery === '' || filterTextLower.includes(filterSearchQuery);
1153                
1154                // Проверяем соответствие фильтру по тональности
1155                const matchesSentiment = filterSentiment === 'all' || sentiment === filterSentiment;
1156                
1157                // Показываем кнопку только если она соответствует обоим условиям
1158                if (matchesSearch && matchesSentiment) {
1159                    btn.style.display = 'inline-block';
1160                } else {
1161                    btn.style.display = 'none';
1162                }
1163                
1164                // Обновляем стили выбранных кнопок
1165                const bgColor = sentiment === 'positive' ? '#28a745' : '#dc3545';
1166                const selectedBgColor = sentiment === 'positive' ? '#1e7e34' : '#bd2130';
1167                
1168                btn.style.background = isSelected ? selectedBgColor : bgColor;
1169                btn.style.fontWeight = isSelected ? '600' : '400';
1170                btn.style.opacity = isSelected ? '1' : '0.85';
1171                btn.style.boxShadow = isSelected ? '0 2px 8px rgba(0, 0, 0, 0.2)' : 'none';
1172            });
1173        }
1174
1175        updateTableContent() {
1176            const tbody = document.getElementById('opinions-table-body');
1177            if (tbody) {
1178                tbody.innerHTML = this.renderTableRows();
1179            }
1180        }
1181
1182        renderTableRows() {
1183            let filteredOpinions = this.opinions;
1184
1185            // Применяем поиск по SKU/названию
1186            if (this.searchQuery) {
1187                filteredOpinions = filteredOpinions.filter(item => 
1188                    item.sku.toLowerCase().includes(this.searchQuery) ||
1189                    item.name.toLowerCase().includes(this.searchQuery)
1190                );
1191            }
1192
1193            // Применяем фильтры по мнениям
1194            if (this.selectedFilters.size > 0) {
1195                if (this.filterLogic === 'AND') {
1196                    // Логика И: товар должен содержать ВСЕ выбранные мнения
1197                    filteredOpinions = filteredOpinions.filter(item => {
1198                        const itemOpinions = new Set(item.opinions.map(op => op.text));
1199                        return Array.from(this.selectedFilters).every(filter => itemOpinions.has(filter));
1200                    });
1201                } else {
1202                    // Логика ИЛИ: товар должен содержать ХОТЯ БЫ ОДНО из выбранных мнений
1203                    filteredOpinions = filteredOpinions.filter(item => 
1204                        item.opinions.some(op => this.selectedFilters.has(op.text))
1205                    );
1206                }
1207            }
1208
1209            // Применяем сортировку
1210            if (this.sortColumn) {
1211                filteredOpinions = [...filteredOpinions].sort((a, b) => {
1212                    let aValue, bValue;
1213                    
1214                    switch (this.sortColumn) {
1215                    case 'sku':
1216                        aValue = a.sku;
1217                        bValue = b.sku;
1218                        break;
1219                    case 'name':
1220                        aValue = a.name.toLowerCase();
1221                        bValue = b.name.toLowerCase();
1222                        break;
1223                    case 'reviews':
1224                        aValue = a.reviewCount;
1225                        bValue = b.reviewCount;
1226                        break;
1227                    case 'positive':
1228                        // Считаем сумму процентов позитивных мнений
1229                        aValue = a.opinions.filter(op => op.sentiment === 'positive').reduce((sum, op) => sum + op.percentage, 0);
1230                        bValue = b.opinions.filter(op => op.sentiment === 'positive').reduce((sum, op) => sum + op.percentage, 0);
1231                        break;
1232                    case 'negative':
1233                        // Считаем сумму процентов негативных мнений
1234                        aValue = a.opinions.filter(op => op.sentiment === 'negative').reduce((sum, op) => sum + op.percentage, 0);
1235                        bValue = b.opinions.filter(op => op.sentiment === 'negative').reduce((sum, op) => sum + op.percentage, 0);
1236                        break;
1237                    }
1238                    
1239                    if (this.sortDirection === 'asc') {
1240                        return aValue > bValue ? 1 : aValue < bValue ? -1 : 0;
1241                    } else {
1242                        return aValue < bValue ? 1 : aValue > bValue ? -1 : 0;
1243                    }
1244                });
1245            }
1246
1247            if (filteredOpinions.length === 0) {
1248                return `
1249                    <tr>
1250                        <td colspan="5" style="padding: 48px; text-align: center; color: #999;">
1251                            ${this.searchQuery ? 'Ничего не найдено по запросу "' + this.searchQuery + '"' : 
1252        this.selectedFilters.size === 0 ? 'Нет собранных данных. Нажмите "Собрать" для начала сбора.' : 
1253            'Нет товаров, соответствующих выбранным фильтрам.'}
1254                        </td>
1255                    </tr>
1256                `;
1257            }
1258
1259            return filteredOpinions.map(item => {
1260                // Подсчитываем статистику по тональности (сумма процентов)
1261                const positiveOpinions = item.opinions.filter(op => op.sentiment === 'positive');
1262                const negativeOpinions = item.opinions.filter(op => op.sentiment === 'negative');
1263                
1264                const positivePercent = positiveOpinions.reduce((sum, op) => sum + op.percentage, 0);
1265                const negativePercent = negativeOpinions.reduce((sum, op) => sum + op.percentage, 0);
1266                
1267                // Фильтруем мнения по выбранной тональности
1268                let displayOpinions = item.opinions;
1269                const sentimentFilter = this.sentimentFilter || 'all';
1270                if (sentimentFilter === 'positive') {
1271                    displayOpinions = positiveOpinions;
1272                } else if (sentimentFilter === 'negative') {
1273                    displayOpinions = negativeOpinions;
1274                }
1275                
1276                return `
1277                <tr style="border-bottom: 1px solid #f0f0f0;">
1278                    <td style="padding: 16px; color: #666; font-family: monospace; font-size: 13px;">${item.sku}</td>
1279                    <td style="padding: 16px; color: #333;">${item.name}</td>
1280                    <td style="padding: 16px; text-align: center; color: #005bff; font-weight: 600;">${item.reviewCount}</td>
1281                    <td style="padding: 16px;">
1282                        <div style="display: flex; flex-direction: column; gap: 6px; align-items: center;">
1283                            <div style="display: flex; align-items: center; gap: 8px;">
1284                                <span style="
1285                                    background: #28a745;
1286                                    color: white;
1287                                    padding: 4px 10px;
1288                                    border-radius: 12px;
1289                                    font-size: 12px;
1290                                    font-weight: 600;
1291                                ">🟢 ${positiveOpinions.length} (${Math.round(positivePercent)}%)</span>
1292                            </div>
1293                            <div style="display: flex; align-items: center; gap: 8px;">
1294                                <span style="
1295                                    background: #dc3545;
1296                                    color: white;
1297                                    padding: 4px 10px;
1298                                    border-radius: 12px;
1299                                    font-size: 12px;
1300                                    font-weight: 600;
1301                                ">🔴 ${negativeOpinions.length} (${Math.round(negativePercent)}%)</span>
1302                            </div>
1303                        </div>
1304                    </td>
1305                    <td style="padding: 16px;">
1306                        <div style="display: flex; flex-direction: column; gap: 6px;">
1307                            ${displayOpinions.map(op => {
1308        const isHighlighted = this.selectedFilters.has(op.text);
1309        const sentiment = op.sentiment || 'positive';
1310        const bgColor = sentiment === 'positive' ? '#d4edda' : '#f8d7da';
1311        const borderColor = sentiment === 'positive' ? '#28a745' : '#dc3545';
1312        const badgeColor = sentiment === 'positive' ? '#28a745' : '#dc3545';
1313                                
1314        return `
1315                                    <div style="
1316                                        display: flex;
1317                                        align-items: center;
1318                                        gap: 8px;
1319                                        padding: 6px 12px;
1320                                        background: ${isHighlighted ? bgColor : '#f8f9fa'};
1321                                        border-radius: 6px;
1322                                        font-size: 13px;
1323                                        border: ${isHighlighted ? '2px solid ' + borderColor : 'none'};
1324                                    ">
1325                                        <span style="flex: 1; color: #333; font-weight: ${isHighlighted ? '600' : '400'};">${op.text}</span>
1326                                        <span style="
1327                                            background: ${badgeColor};
1328                                            color: white;
1329                                            padding: 2px 8px;
1330                                            border-radius: 12px;
1331                                            font-size: 12px;
1332                                            font-weight: 600;
1333                                        ">${op.percentage}%</span>
1334                                    </div>
1335                                `;
1336    }).join('')}
1337                        </div>
1338                    </td>
1339                </tr>
1340            `;
1341            }).join('');
1342        }
1343
1344        applyFilter(filter) {
1345            const tbody = document.getElementById('opinions-table-body');
1346            if (tbody) {
1347                tbody.innerHTML = this.renderTableRows(filter);
1348            }
1349        }
1350
1351        closeTable() {
1352            if (this.tableModal) {
1353                this.tableModal.remove();
1354                this.tableModal = null;
1355            }
1356        }
1357
1358        downloadCSV() {
1359            if (this.opinions.length === 0) {
1360                alert('Нет данных для скачивания. Сначала соберите мнения!');
1361                return;
1362            }
1363
1364            console.log('OpinionsCollector: Генерация CSV файла');
1365
1366            // Создаем CSV контент
1367            let csvContent = '\uFEFF'; // BOM для корректного отображения кириллицы в Excel
1368            
1369            // Заголовки
1370            csvContent += 'SKU;Название;Количество отзывов;Позитивные мнения;Негативные мнения;Все мнения\n';
1371            
1372            // Данные
1373            this.opinions.forEach(item => {
1374                const positiveOpinions = item.opinions.filter(op => op.sentiment === 'positive');
1375                const negativeOpinions = item.opinions.filter(op => op.sentiment === 'negative');
1376                
1377                // Форматируем мнения
1378                const positiveText = positiveOpinions.map(op => `${op.text} (${op.percentage}%)`).join('; ');
1379                const negativeText = negativeOpinions.map(op => `${op.text} (${op.percentage}%)`).join('; ');
1380                const allOpinionsText = item.opinions.map(op => `${op.text} (${op.percentage}%)`).join('; ');
1381                
1382                // Экранируем кавычки и переносы строк
1383                const escapeCsv = (text) => {
1384                    if (!text) return '';
1385                    return '"' + text.replace(/"/g, '""') + '"';
1386                };
1387                
1388                csvContent += `${escapeCsv(item.sku)};${escapeCsv(item.name)};${item.reviewCount};${escapeCsv(positiveText)};${escapeCsv(negativeText)};${escapeCsv(allOpinionsText)}\n`;
1389            });
1390            
1391            // Создаем Blob и скачиваем файл
1392            const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
1393            const link = document.createElement('a');
1394            const url = URL.createObjectURL(blob);
1395            
1396            // Генерируем имя файла с датой
1397            const date = new Date();
1398            const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
1399            const fileName = `ozon_opinions_${dateStr}.csv`;
1400            
1401            link.setAttribute('href', url);
1402            link.setAttribute('download', fileName);
1403            link.style.visibility = 'hidden';
1404            document.body.appendChild(link);
1405            link.click();
1406            document.body.removeChild(link);
1407            
1408            console.log(`OpinionsCollector: CSV файл "${fileName}" скачан`);
1409        }
1410
1411        importCSV() {
1412            console.log('OpinionsCollector: Импорт CSV файла');
1413            
1414            // Создаем скрытый input для выбора файла
1415            const fileInput = document.createElement('input');
1416            fileInput.type = 'file';
1417            fileInput.accept = '.csv';
1418            fileInput.style.display = 'none';
1419            
1420            fileInput.addEventListener('change', async (e) => {
1421                const file = e.target.files[0];
1422                if (!file) return;
1423                
1424                console.log('OpinionsCollector: Файл выбран:', file.name);
1425                
1426                try {
1427                    const text = await file.text();
1428                    const lines = text.split('\n');
1429                    
1430                    // Пропускаем BOM и заголовок
1431                    const dataLines = lines.slice(1).filter(line => line.trim());
1432                    
1433                    const importedOpinions = [];
1434                    
1435                    dataLines.forEach(line => {
1436                        // Парсим CSV строку с учетом кавычек
1437                        const values = this.parseCSVLine(line);
1438                        
1439                        if (values.length < 6) return;
1440                        
1441                        const sku = values[0];
1442                        const name = values[1];
1443                        const reviewCount = parseInt(values[2]) || 0;
1444                        const positiveText = values[3];
1445                        const negativeText = values[4];
1446                        const allOpinionsText = values[5];
1447                        
1448                        // Парсим мнения из строки "Мнение (процент%); Мнение2 (процент%)"
1449                        const opinions = [];
1450                        
1451                        // Парсим позитивные мнения
1452                        if (positiveText) {
1453                            const positiveItems = positiveText.split(';').map(s => s.trim()).filter(s => s);
1454                            positiveItems.forEach(item => {
1455                                const match = item.match(/(.+?)\s*\((.+?)%\)/);
1456                                if (match) {
1457                                    opinions.push({
1458                                        text: match[1].trim(),
1459                                        percentage: parseFloat(match[2]),
1460                                        sentiment: 'positive'
1461                                    });
1462                                }
1463                            });
1464                        }
1465                        
1466                        // Парсим негативные мнения
1467                        if (negativeText) {
1468                            const negativeItems = negativeText.split(';').map(s => s.trim()).filter(s => s);
1469                            negativeItems.forEach(item => {
1470                                const match = item.match(/(.+?)\s*\((.+?)%\)/);
1471                                if (match) {
1472                                    opinions.push({
1473                                        text: match[1].trim(),
1474                                        percentage: parseFloat(match[2]),
1475                                        sentiment: 'negative'
1476                                    });
1477                                }
1478                            });
1479                        }
1480                        
1481                        if (opinions.length > 0) {
1482                            importedOpinions.push({
1483                                sku: sku,
1484                                name: name,
1485                                reviewCount: reviewCount,
1486                                opinions: opinions
1487                            });
1488                        }
1489                    });
1490                    
1491                    if (importedOpinions.length === 0) {
1492                        alert('Не удалось импортировать данные. Проверьте формат файла.');
1493                        return;
1494                    }
1495                    
1496                    // Объединяем с существующими данными
1497                    const confirm = window.confirm(`Найдено ${importedOpinions.length} товаров. Заменить существующие данные (${this.opinions.length} товаров)?`);
1498                    
1499                    if (confirm) {
1500                        this.opinions = importedOpinions;
1501                    } else {
1502                        // Добавляем к существующим, избегая дубликатов по SKU
1503                        const existingSKUs = new Set(this.opinions.map(item => item.sku));
1504                        importedOpinions.forEach(item => {
1505                            if (!existingSKUs.has(item.sku)) {
1506                                this.opinions.push(item);
1507                            }
1508                        });
1509                    }
1510                    
1511                    // Обновляем список всех мнений
1512                    this.allOpinions.clear();
1513                    this.opinions.forEach(item => {
1514                        if (item.opinions && Array.isArray(item.opinions)) {
1515                            item.opinions.forEach(op => {
1516                                this.allOpinions.add(op.text);
1517                            });
1518                        }
1519                    });
1520                    
1521                    // Сохраняем данные
1522                    await this.saveOpinions();
1523                    
1524                    // Обновляем счетчик
1525                    this.updateCounter();
1526                    
1527                    alert(`Импорт завершен! Загружено ${importedOpinions.length} товаров. Всего в базе: ${this.opinions.length} товаров.`);
1528                    
1529                    console.log('OpinionsCollector: Импорт завершен');
1530                    
1531                } catch (error) {
1532                    console.error('OpinionsCollector: Ошибка импорта', error);
1533                    alert('Ошибка при импорте файла. Проверьте формат CSV.');
1534                }
1535                
1536                // Удаляем input
1537                document.body.removeChild(fileInput);
1538            });
1539            
1540            document.body.appendChild(fileInput);
1541            fileInput.click();
1542        }
1543
1544        parseCSVLine(line) {
1545            const values = [];
1546            let current = '';
1547            let inQuotes = false;
1548            
1549            for (let i = 0; i < line.length; i++) {
1550                const char = line[i];
1551                const nextChar = line[i + 1];
1552                
1553                if (char === '"') {
1554                    if (inQuotes && nextChar === '"') {
1555                        // Экранированная кавычка
1556                        current += '"';
1557                        i++; // Пропускаем следующую кавычку
1558                    } else {
1559                        // Начало или конец строки в кавычках
1560                        inQuotes = !inQuotes;
1561                    }
1562                } else if (char === ';' && !inQuotes) {
1563                    // Разделитель вне кавычек
1564                    values.push(current.trim());
1565                    current = '';
1566                } else {
1567                    current += char;
1568                }
1569            }
1570            
1571            // Добавляем последнее значение
1572            values.push(current.trim());
1573            
1574            return values;
1575        }
1576
1577        sleep(ms) {
1578            return new Promise(resolve => setTimeout(resolve, ms));
1579        }
1580    }
1581
1582    // Инициализация при загрузке страницы
1583    if (document.readyState === 'loading') {
1584        document.addEventListener('DOMContentLoaded', () => {
1585            const collector = new OpinionsCollector();
1586            collector.init();
1587        });
1588    } else {
1589        const collector = new OpinionsCollector();
1590        collector.init();
1591    }
1592
1593})();
Ozon Opinions Collector | Robomonkey