Ozon Opinions Collector

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

Size

58.9 KB

Version

1.6.1

Created

Mar 19, 2026

Updated

28 days ago

1// ==UserScript==
2// @name		Ozon Opinions Collector
3// @description		Собирает мнения покупателей о товарах на Ozon Seller
4// @version		1.6.1
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-collect-btn" style="
152                        background: rgba(255, 255, 255, 0.2);
153                        color: white;
154                        border: 2px solid white;
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                    " onmouseover="this.style.background='white'; this.style.color='#005bff'" onmouseout="this.style.background='rgba(255, 255, 255, 0.2)'; this.style.color='white'">
162                        🔄 Собрать
163                    </button>
164                </div>
165            `;
166
167            // Вставляем панель в начало страницы
168            if (header.tagName === 'HEADER') {
169                header.after(panel);
170            } else {
171                document.body.insertBefore(panel, document.body.firstChild);
172            }
173
174            this.controlPanel = panel;
175
176            // Добавляем обработчики событий
177            document.getElementById('opinions-view-btn').addEventListener('click', () => this.showOpinionsTable());
178            document.getElementById('opinions-collect-btn').addEventListener('click', () => this.startCollecting());
179
180            console.log('OpinionsCollector: Панель управления создана');
181        }
182
183        async startCollecting() {
184            if (this.isCollecting) {
185                console.log('OpinionsCollector: Сбор уже идет');
186                return;
187            }
188
189            this.isCollecting = true;
190            const collectBtn = document.getElementById('opinions-collect-btn');
191            const originalText = collectBtn.innerHTML;
192            collectBtn.innerHTML = '⏳ Собираю...';
193            collectBtn.disabled = true;
194
195            console.log('OpinionsCollector: Начинаю сбор мнений');
196
197            try {
198                // Очищаем старые данные
199                this.opinions = [];
200                this.allOpinions.clear();
201
202                // Получаем количество страниц
203                const pagination = document.querySelector('#pagination');
204                let totalPages = 1;
205                if (pagination) {
206                    const pageButtons = pagination.querySelectorAll('button[type="button"]');
207                    totalPages = pageButtons.length;
208                }
209
210                console.log(`OpinionsCollector: Найдено ${totalPages} страниц`);
211
212                // Создаем прогресс-бар
213                this.showProgressBar(0, totalPages);
214
215                // Собираем данные со всех страниц
216                for (let page = 1; page <= totalPages; page++) {
217                    console.log(`OpinionsCollector: Обработка страницы ${page}/${totalPages}`);
218                    
219                    // Если не первая страница, переходим на нее
220                    if (page > 1) {
221                        await this.goToPage(page);
222                        await this.sleep(3000); // Ждем загрузки
223                    }
224
225                    // Собираем мнения с текущей страницы
226                    await this.collectCurrentPage();
227
228                    // Обновляем прогресс
229                    this.updateProgressBar(page, totalPages);
230                }
231
232                // Сохраняем данные
233                await this.saveOpinions();
234
235                // Обновляем счетчик
236                this.updateCounter();
237
238                // Показываем таблицу
239                this.showOpinionsTable();
240
241                console.log('OpinionsCollector: Сбор завершен');
242                collectBtn.innerHTML = '✅ Готово!';
243                setTimeout(() => {
244                    collectBtn.innerHTML = originalText;
245                    collectBtn.disabled = false;
246                }, 2000);
247
248            } catch (e) {
249                console.error('OpinionsCollector: Ошибка при сборе', e);
250                collectBtn.innerHTML = '❌ Ошибка';
251                setTimeout(() => {
252                    collectBtn.innerHTML = originalText;
253                    collectBtn.disabled = false;
254                }, 2000);
255            } finally {
256                this.isCollecting = false;
257                this.hideProgressBar();
258            }
259        }
260
261        async goToPage(pageNumber) {
262            const pagination = document.querySelector('#pagination');
263            if (!pagination) return;
264
265            const pageButtons = pagination.querySelectorAll('button[type="button"]');
266            const targetButton = Array.from(pageButtons).find(btn => 
267                btn.textContent.trim() === String(pageNumber)
268            );
269
270            if (targetButton) {
271                targetButton.click();
272                console.log(`OpinionsCollector: Переход на страницу ${pageNumber}`);
273            }
274        }
275
276        async collectCurrentPage() {
277            const rows = document.querySelectorAll('tbody tr');
278            console.log(`OpinionsCollector: Найдено ${rows.length} строк на странице`);
279
280            for (let i = 0; i < rows.length; i++) {
281                const row = rows[i];
282                const cells = row.querySelectorAll('td');
283                
284                if (cells.length < 12) continue;
285
286                // Получаем данные товара
287                const sku = cells[3] ? cells[3].textContent.trim().split('SKU')[1]?.trim() : '';
288                const name = cells[4] ? cells[4].textContent.trim() : '';
289                const reviewCountCell = cells[11];
290                const reviewCount = reviewCountCell ? reviewCountCell.textContent.trim() : '0';
291
292                // Пропускаем товары без отзывов
293                if (reviewCount === '0' || !reviewCount) {
294                    continue;
295                }
296
297                console.log(`OpinionsCollector: Обработка товара ${i + 1}: SKU=${sku}, Отзывов=${reviewCount}`);
298
299                // Наводим курсор на ячейку с отзывами
300                const opinions = await this.extractOpinionsFromCell(reviewCountCell);
301
302                if (opinions && opinions.length > 0) {
303                    this.opinions.push({
304                        sku: sku,
305                        name: name,
306                        reviewCount: parseInt(reviewCount) || 0,
307                        opinions: opinions
308                    });
309
310                    // Добавляем мнения в общий список
311                    opinions.forEach(op => {
312                        this.allOpinions.add(op.text);
313                    });
314
315                    console.log(`OpinionsCollector: Найдено ${opinions.length} мнений для товара ${sku}`);
316                }
317
318                // Небольшая задержка между товарами
319                await this.sleep(200);
320            }
321        }
322
323        async extractOpinionsFromCell(cell) {
324            return new Promise((resolve) => {
325                // Ищем элемент с классом ct1110-a - это триггер для тултипа
326                const triggerElement = cell.querySelector('.ct1110-a');
327                
328                if (!triggerElement) {
329                    console.log('OpinionsCollector: Триггер элемент не найден');
330                    resolve([]);
331                    return;
332                }
333                
334                console.log('OpinionsCollector: Наведение курсора на элемент', triggerElement.textContent.trim().substring(0, 50));
335                
336                // Наводим курсор на триггер элемент
337                const mouseEnterEvent = new MouseEvent('mouseenter', {
338                    view: window,
339                    bubbles: true,
340                    cancelable: true
341                });
342                triggerElement.dispatchEvent(mouseEnterEvent);
343                
344                const mouseOverEvent = new MouseEvent('mouseover', {
345                    view: window,
346                    bubbles: true,
347                    cancelable: true
348                });
349                triggerElement.dispatchEvent(mouseOverEvent);
350
351                // Ждем появления тултипа
352                setTimeout(() => {
353                    const tooltip = document.querySelector('[data-tippy-root] .tippy-content');
354                    
355                    if (!tooltip) {
356                        console.log('OpinionsCollector: Тултип не найден');
357                        
358                        // Убираем курсор
359                        const mouseLeaveEvent = new MouseEvent('mouseleave', {
360                            view: window,
361                            bubbles: true,
362                            cancelable: true
363                        });
364                        triggerElement.dispatchEvent(mouseLeaveEvent);
365                        
366                        resolve([]);
367                        return;
368                    }
369
370                    console.log('OpinionsCollector: Тултип найден');
371
372                    // Ищем кнопку "Посмотреть ещё"
373                    const moreButton = tooltip.querySelector('button');
374                    if (moreButton && moreButton.textContent.includes('Посмотреть ещё')) {
375                        console.log('OpinionsCollector: Нажимаю кнопку "Посмотреть ещё"');
376                        moreButton.click();
377                        
378                        // Ждем открытия модального окна
379                        setTimeout(() => {
380                            const modal = document.querySelector('.sc1110-a');
381                            if (modal) {
382                                const opinions = this.parseOpinionsFromModal(modal);
383                                console.log('OpinionsCollector: Извлечено мнений из модального окна:', opinions.length);
384                                
385                                // Закрываем модальное окно
386                                const closeButton = modal.querySelector('button[aria-label="Закрыть"]') || 
387                                                   modal.querySelector('.sc1110-a3 button') ||
388                                                   document.querySelector('[class*="close"]');
389                                if (closeButton) {
390                                    closeButton.click();
391                                }
392                                
393                                // Убираем курсор
394                                const mouseLeaveEvent = new MouseEvent('mouseleave', {
395                                    view: window,
396                                    bubbles: true,
397                                    cancelable: true
398                                });
399                                triggerElement.dispatchEvent(mouseLeaveEvent);
400                                
401                                resolve(opinions);
402                            } else {
403                                // Если модальное окно не открылось, парсим из тултипа
404                                const opinions = this.parseOpinionsFromTooltip(tooltip);
405                                console.log('OpinionsCollector: Извлечено мнений из тултипа:', opinions.length);
406                                
407                                // Убираем курсор
408                                const mouseLeaveEvent = new MouseEvent('mouseleave', {
409                                    view: window,
410                                    bubbles: true,
411                                    cancelable: true
412                                });
413                                triggerElement.dispatchEvent(mouseLeaveEvent);
414                                
415                                resolve(opinions);
416                            }
417                        }, 2000);
418                    } else {
419                        const opinions = this.parseOpinionsFromTooltip(tooltip);
420                        console.log('OpinionsCollector: Извлечено мнений:', opinions.length);
421                        
422                        // Убираем курсор
423                        const mouseLeaveEvent = new MouseEvent('mouseleave', {
424                            view: window,
425                            bubbles: true,
426                            cancelable: true
427                        });
428                        triggerElement.dispatchEvent(mouseLeaveEvent);
429                        
430                        resolve(opinions);
431                    }
432                }, 2000);
433            });
434        }
435
436        parseOpinionsFromModal(modal) {
437            const opinions = [];
438            const rows = modal.querySelectorAll('tbody tr');
439            
440            console.log('OpinionsCollector: Найдено строк в модальном окне:', rows.length);
441            
442            rows.forEach(row => {
443                const svg = row.querySelector('svg');
444                const textElement = row.querySelector('.dn0-t7');
445                const percentElement = row.querySelector('.dn0-t6');
446                
447                if (!textElement) return;
448                
449                const opinionText = textElement.textContent.trim();
450                
451                // Определяем тональность по классу SVG
452                let sentiment = 'positive';
453                if (svg) {
454                    const svgClass = svg.className.baseVal || svg.getAttribute('class') || '';
455                    // dn0-u0 - негативный (грустный смайлик)
456                    // dn0-u1 - позитивный (улыбающийся смайлик)
457                    if (svgClass.includes('dn0-u0')) {
458                        sentiment = 'negative';
459                    } else if (svgClass.includes('dn0-u1')) {
460                        sentiment = 'positive';
461                    }
462                }
463                
464                // Получаем процент
465                let percentage = 0;
466                if (percentElement) {
467                    const percentText = percentElement.textContent.trim();
468                    if (percentText.includes('<')) {
469                        percentage = 0.5;
470                    } else {
471                        percentage = parseFloat(percentText.replace('%', ''));
472                    }
473                }
474                
475                opinions.push({
476                    text: opinionText,
477                    percentage: percentage,
478                    sentiment: sentiment
479                });
480            });
481            
482            return opinions;
483        }
484
485        parseOpinionsFromTooltip(tooltip) {
486            const opinions = [];
487            const listItems = tooltip.querySelectorAll('ul li p');
488            
489            listItems.forEach(item => {
490                const text = item.textContent.trim();
491                
492                // Пропускаем элемент "И ещё X"
493                if (text.startsWith('И ещё')) {
494                    return;
495                }
496                
497                const match = text.match(/(.+?)\s*—\s*(.+?)$/);
498                
499                if (match) {
500                    const opinionText = match[1].trim();
501                    const percentageText = match[2].trim();
502                    
503                    // Парсим процент (может быть "6%" или "< 1%")
504                    let percentage = 0;
505                    if (percentageText.includes('<')) {
506                        percentage = 0.5; // Для "< 1%" ставим 0.5
507                    } else {
508                        percentage = parseFloat(percentageText.replace('%', ''));
509                    }
510                    
511                    opinions.push({
512                        text: opinionText,
513                        percentage: percentage
514                    });
515                }
516            });
517
518            return opinions;
519        }
520
521        showProgressBar(current, total) {
522            const progressBar = document.createElement('div');
523            progressBar.id = 'opinions-progress-bar';
524            progressBar.innerHTML = `
525                <div style="
526                    position: fixed;
527                    top: 50%;
528                    left: 50%;
529                    transform: translate(-50%, -50%);
530                    background: white;
531                    padding: 32px;
532                    border-radius: 16px;
533                    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
534                    z-index: 10000;
535                    min-width: 400px;
536                    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
537                ">
538                    <div style="font-size: 18px; font-weight: 600; margin-bottom: 16px; color: #333;">
539                        🔄 Сбор мнений...
540                    </div>
541                    <div style="
542                        background: #f0f0f0;
543                        height: 8px;
544                        border-radius: 4px;
545                        overflow: hidden;
546                        margin-bottom: 12px;
547                    ">
548                        <div id="progress-bar-fill" style="
549                            background: linear-gradient(90deg, #005bff 0%, #0041b8 100%);
550                            height: 100%;
551                            width: 0%;
552                            transition: width 0.3s;
553                        "></div>
554                    </div>
555                    <div id="progress-text" style="
556                        font-size: 14px;
557                        color: #666;
558                        text-align: center;
559                    ">
560                        Страница ${current} из ${total}
561                    </div>
562                </div>
563                <div style="
564                    position: fixed;
565                    top: 0;
566                    left: 0;
567                    width: 100%;
568                    height: 100%;
569                    background: rgba(0, 0, 0, 0.5);
570                    z-index: 9999;
571                "></div>
572            `;
573            document.body.appendChild(progressBar);
574        }
575
576        updateProgressBar(current, total) {
577            const fill = document.getElementById('progress-bar-fill');
578            const text = document.getElementById('progress-text');
579            
580            if (fill && text) {
581                const percentage = (current / total) * 100;
582                fill.style.width = `${percentage}%`;
583                text.textContent = `Страница ${current} из ${total} • Собрано товаров: ${this.opinions.length}`;
584            }
585        }
586
587        hideProgressBar() {
588            const progressBar = document.getElementById('opinions-progress-bar');
589            if (progressBar) {
590                progressBar.remove();
591            }
592        }
593
594        updateCounter() {
595            const counter = document.getElementById('opinions-counter');
596            if (counter) {
597                counter.textContent = `Собрано: ${this.opinions.length} товаров`;
598            }
599        }
600
601        showOpinionsTable() {
602            // Удаляем старую таблицу если есть
603            if (this.tableModal) {
604                this.tableModal.remove();
605            }
606
607            // Создаем модальное окно с таблицей
608            const modal = document.createElement('div');
609            modal.id = 'opinions-table-modal';
610            
611            // Получаем все уникальные мнения для фильтров
612            const allOpinionsArray = Array.from(this.allOpinions).sort();
613            
614            // Создаем карту мнений с их тональностью
615            const opinionSentimentMap = new Map();
616            this.opinions.forEach(item => {
617                if (item.opinions && Array.isArray(item.opinions)) {
618                    item.opinions.forEach(op => {
619                        if (!opinionSentimentMap.has(op.text)) {
620                            opinionSentimentMap.set(op.text, op.sentiment || 'positive');
621                        }
622                    });
623                }
624            });
625
626            modal.innerHTML = `
627                <div style="
628                    position: fixed;
629                    top: 0;
630                    left: 0;
631                    width: 100%;
632                    height: 100%;
633                    background: rgba(0, 0, 0, 0.5);
634                    z-index: 9998;
635                " id="modal-backdrop"></div>
636                <div style="
637                    position: fixed;
638                    top: 50%;
639                    left: 50%;
640                    transform: translate(-50%, -50%);
641                    background: white;
642                    border-radius: 16px;
643                    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
644                    z-index: 9999;
645                    width: 90%;
646                    max-width: 1400px;
647                    max-height: 90vh;
648                    display: flex;
649                    flex-direction: column;
650                    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
651                ">
652                    <!-- Заголовок -->
653                    <div style="
654                        padding: 24px;
655                        border-bottom: 1px solid #e0e0e0;
656                        display: flex;
657                        justify-content: space-between;
658                        align-items: center;
659                    ">
660                        <div>
661                            <h2 style="margin: 0; font-size: 24px; color: #333;">📊 Мнения покупателей</h2>
662                            <p style="margin: 8px 0 0 0; color: #666; font-size: 14px;">Всего товаров: ${this.opinions.length}</p>
663                        </div>
664                        <button id="close-modal-btn" style="
665                            background: none;
666                            border: none;
667                            font-size: 32px;
668                            cursor: pointer;
669                            color: #999;
670                            line-height: 1;
671                            padding: 0;
672                            width: 40px;
673                            height: 40px;
674                        ">×</button>
675                    </div>
676
677                    <!-- Поиск -->
678                    <div style="
679                        padding: 16px 24px;
680                        border-bottom: 1px solid #e0e0e0;
681                        background: white;
682                    ">
683                        <div style="display: flex; gap: 12px; align-items: center;">
684                            <div style="flex: 1;">
685                                <input 
686                                    type="text" 
687                                    id="search-input" 
688                                    placeholder="🔍 Поиск по SKU или названию товара..."
689                                    style="
690                                        width: 100%;
691                                        padding: 10px 16px;
692                                        border: 2px solid #e0e0e0;
693                                        border-radius: 8px;
694                                        font-size: 14px;
695                                        transition: all 0.2s;
696                                        outline: none;
697                                    "
698                                    onfocus="this.style.borderColor='#005bff'"
699                                    onblur="this.style.borderColor='#e0e0e0'"
700                                />
701                            </div>
702                            <div style="flex: 1;">
703                                <input 
704                                    type="text" 
705                                    id="filter-search-input" 
706                                    placeholder="🔍 Поиск по мнениям..."
707                                    style="
708                                        width: 100%;
709                                        padding: 10px 16px;
710                                        border: 2px solid #e0e0e0;
711                                        border-radius: 8px;
712                                        font-size: 14px;
713                                        transition: all 0.2s;
714                                        outline: none;
715                                    "
716                                    onfocus="this.style.borderColor='#005bff'"
717                                    onblur="this.style.borderColor='#e0e0e0'"
718                                />
719                            </div>
720                        </div>
721                    </div>
722
723                    <!-- Фильтры -->
724                    <div style="
725                        padding: 16px 24px;
726                        border-bottom: 1px solid #e0e0e0;
727                        background: #f8f9fa;
728                    ">
729                        <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
730                            <div style="display: flex; gap: 12px; align-items: center;">
731                                <button id="toggle-filters-btn" style="
732                                    background: #6c757d;
733                                    color: white;
734                                    border: none;
735                                    padding: 8px 16px;
736                                    border-radius: 8px;
737                                    font-size: 13px;
738                                    font-weight: 600;
739                                    cursor: pointer;
740                                    transition: all 0.2s;
741                                    display: flex;
742                                    align-items: center;
743                                    gap: 6px;
744                                " onmouseover="this.style.background='#5a6268'" onmouseout="this.style.background='#6c757d'">
745                                    <span id="filter-toggle-icon"></span>
746                                    Фильтры по мнениям
747                                </button>
748                                <select id="filter-sentiment-type" style="
749                                    padding: 6px 12px;
750                                    border-radius: 8px;
751                                    border: 1px solid #ddd;
752                                    font-size: 13px;
753                                    cursor: pointer;
754                                    background: white;
755                                ">
756                                    <option value="all">Все фильтры</option>
757                                    <option value="positive">🟢 Только позитивные</option>
758                                    <option value="negative">🔴 Только негативные</option>
759                                </select>
760                            </div>
761                            <div style="display: flex; gap: 8px; align-items: center;">
762                                <span style="font-size: 13px; color: #666;">Показать:</span>
763                                <select id="sentiment-filter" style="
764                                    padding: 6px 12px;
765                                    border-radius: 8px;
766                                    border: 1px solid #ddd;
767                                    font-size: 13px;
768                                    cursor: pointer;
769                                    background: white;
770                                ">
771                                    <option value="all">Все мнения</option>
772                                    <option value="positive">Только позитивные</option>
773                                    <option value="negative">Только негативные</option>
774                                </select>
775                                <span style="font-size: 13px; color: #666;">Логика:</span>
776                                <button id="logic-toggle-btn" style="
777                                    background: #005bff;
778                                    color: white;
779                                    border: none;
780                                    padding: 6px 16px;
781                                    border-radius: 20px;
782                                    font-size: 13px;
783                                    font-weight: 600;
784                                    cursor: pointer;
785                                    transition: all 0.2s;
786                                ">${this.filterLogic}</button>
787                                <button id="clear-filters-btn" style="
788                                    background: #dc3545;
789                                    color: white;
790                                    border: none;
791                                    padding: 6px 16px;
792                                    border-radius: 20px;
793                                    font-size: 13px;
794                                    font-weight: 600;
795                                    cursor: pointer;
796                                    transition: all 0.2s;
797                                    ${this.selectedFilters.size === 0 ? 'opacity: 0.5; cursor: not-allowed;' : ''}
798                                ">Сбросить фильтры</button>
799                            </div>
800                        </div>
801                        
802                        <!-- Выпадающая панель с фильтрами -->
803                        <div id="filters-panel" style="
804                            max-height: 0;
805                            overflow: hidden;
806                            transition: max-height 0.3s ease-out;
807                        ">
808                            <div style="padding-top: 12px;">
809                                <div style="display: flex; flex-wrap: wrap; gap: 8px; max-height: 200px; overflow-y: auto; padding: 8px;" id="opinion-filters">
810                                    ${allOpinionsArray.map(opinion => {
811        const isSelected = this.selectedFilters.has(opinion);
812        const sentiment = opinionSentimentMap.get(opinion) || 'positive';
813        const bgColor = sentiment === 'positive' ? '#28a745' : '#dc3545';
814        const selectedBgColor = sentiment === 'positive' ? '#1e7e34' : '#bd2130';
815                                        
816        return `
817                                            <button class="filter-btn" data-filter="${opinion}" data-sentiment="${sentiment}" style="
818                                                background: ${isSelected ? selectedBgColor : bgColor};
819                                                color: white;
820                                                border: none;
821                                                padding: 8px 16px;
822                                                border-radius: 20px;
823                                                font-size: 13px;
824                                                cursor: pointer;
825                                                transition: all 0.2s;
826                                                font-weight: ${isSelected ? '600' : '400'};
827                                                opacity: ${isSelected ? '1' : '0.85'};
828                                                box-shadow: ${isSelected ? '0 2px 8px rgba(0, 0, 0, 0.2)' : 'none'};
829                                            " 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>
830                                        `;
831    }).join('')}
832                                </div>
833                            </div>
834                        </div>
835                        
836                        ${this.selectedFilters.size > 0 ? `
837                            <div style="margin-top: 12px; padding: 8px 12px; background: #e7f3ff; border-radius: 8px; font-size: 13px; color: #005bff;">
838                                <strong>Активные фильтры (${this.selectedFilters.size}):</strong> 
839                                ${Array.from(this.selectedFilters).join(this.filterLogic === 'AND' ? ' И ' : ' ИЛИ ')}
840                            </div>
841                        ` : ''}
842                    </div>
843
844                    <!-- Таблица -->
845                    <div style="
846                        flex: 1;
847                        overflow: auto;
848                        padding: 24px;
849                    ">
850                        <table id="opinions-data-table" style="
851                            width: 100%;
852                            border-collapse: collapse;
853                        ">
854                            <thead>
855                                <tr style="background: #f8f9fa; border-bottom: 2px solid #e0e0e0;">
856                                    <th id="sort-sku" style="padding: 12px; text-align: left; font-weight: 600; color: #333; width: 120px; cursor: pointer; user-select: none;" title="Нажмите для сортировки">
857                                        SKU <span style="font-size: 10px;"></span>
858                                    </th>
859                                    <th id="sort-name" style="padding: 12px; text-align: left; font-weight: 600; color: #333; cursor: pointer; user-select: none;" title="Нажмите для сортировки">
860                                        Название <span style="font-size: 10px;"></span>
861                                    </th>
862                                    <th id="sort-reviews" style="padding: 12px; text-align: center; font-weight: 600; color: #333; width: 100px; cursor: pointer; user-select: none;" title="Нажмите для сортировки">
863                                        Отзывов <span style="font-size: 10px;"></span>
864                                    </th>
865                                    <th style="padding: 12px; text-align: center; font-weight: 600; color: #333; width: 150px;">
866                                        <div style="display: flex; flex-direction: column; gap: 4px; align-items: center;">
867                                            <div id="sort-positive" style="cursor: pointer; user-select: none;" title="Нажмите для сортировки">
868                                                🟢 Позитивные <span style="font-size: 10px;"></span>
869                                            </div>
870                                            <div id="sort-negative" style="cursor: pointer; user-select: none;" title="Нажмите для сортировки">
871                                                🔴 Негативные <span style="font-size: 10px;"></span>
872                                            </div>
873                                        </div>
874                                    </th>
875                                    <th style="padding: 12px; text-align: left; font-weight: 600; color: #333; width: 400px;">Мнения</th>
876                                </tr>
877                            </thead>
878                            <tbody id="opinions-table-body">
879                                ${this.renderTableRows()}
880                            </tbody>
881                        </table>
882                    </div>
883                </div>
884            `;
885
886            document.body.appendChild(modal);
887            this.tableModal = modal;
888
889            // Обработчики событий
890            document.getElementById('close-modal-btn').addEventListener('click', () => this.closeTable());
891            document.getElementById('modal-backdrop').addEventListener('click', () => this.closeTable());
892
893            // Обработчик поиска по SKU/названию
894            document.getElementById('search-input').addEventListener('input', (e) => {
895                this.searchQuery = e.target.value.toLowerCase();
896                this.updateTableContent();
897            });
898
899            // Обработчик поиска по фильтрам
900            document.getElementById('filter-search-input').addEventListener('input', (e) => {
901                this.filterSearchQuery = e.target.value.toLowerCase();
902                this.updateFilterButtons();
903            });
904
905            // Обработчик переключения видимости фильтров
906            document.getElementById('toggle-filters-btn').addEventListener('click', () => {
907                const filtersPanel = document.getElementById('filters-panel');
908                const icon = document.getElementById('filter-toggle-icon');
909                
910                if (filtersPanel.style.maxHeight === '0px' || filtersPanel.style.maxHeight === '') {
911                    filtersPanel.style.maxHeight = '250px';
912                    icon.textContent = '▲';
913                } else {
914                    filtersPanel.style.maxHeight = '0px';
915                    icon.textContent = '▼';
916                }
917            });
918
919            // Обработчик фильтра по тональности
920            document.getElementById('sentiment-filter').addEventListener('change', (e) => {
921                this.sentimentFilter = e.target.value;
922                this.updateTableContent();
923            });
924
925            // Обработчик фильтра типа фильтров (позитивные/негативные/все)
926            document.getElementById('filter-sentiment-type').addEventListener('change', (e) => {
927                this.filterSentimentType = e.target.value;
928                this.updateFilterButtons();
929            });
930
931            // Обработчик переключения логики
932            document.getElementById('logic-toggle-btn').addEventListener('click', () => {
933                this.filterLogic = this.filterLogic === 'AND' ? 'OR' : 'AND';
934                document.getElementById('logic-toggle-btn').textContent = this.filterLogic;
935                this.updateTableContent();
936            });
937
938            // Обработчик сброса фильтров
939            document.getElementById('clear-filters-btn').addEventListener('click', () => {
940                if (this.selectedFilters.size > 0) {
941                    this.selectedFilters.clear();
942                    this.updateTableContent();
943                    this.updateFilterButtons();
944                }
945            });
946
947            // Обработчики сортировки
948            document.getElementById('sort-sku').addEventListener('click', () => this.sortTable('sku'));
949            document.getElementById('sort-name').addEventListener('click', () => this.sortTable('name'));
950            document.getElementById('sort-reviews').addEventListener('click', () => this.sortTable('reviews'));
951            document.getElementById('sort-positive').addEventListener('click', () => this.sortTable('positive'));
952            document.getElementById('sort-negative').addEventListener('click', () => this.sortTable('negative'));
953
954            // Обработчики фильтров
955            const filterButtons = modal.querySelectorAll('.filter-btn');
956            filterButtons.forEach(btn => {
957                btn.addEventListener('click', (e) => {
958                    const filter = e.target.getAttribute('data-filter');
959                    
960                    // Переключаем выбор фильтра
961                    if (this.selectedFilters.has(filter)) {
962                        this.selectedFilters.delete(filter);
963                    } else {
964                        this.selectedFilters.add(filter);
965                    }
966
967                    // Обновляем только таблицу и кнопки фильтров, не перерисовываем всё
968                    this.updateTableContent();
969                    this.updateFilterButtons();
970                });
971            });
972
973            console.log('OpinionsCollector: Таблица отображена');
974        }
975
976        sortTable(column) {
977            // Переключаем направление сортировки
978            if (this.sortColumn === column) {
979                this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
980            } else {
981                this.sortColumn = column;
982                this.sortDirection = 'desc'; // По умолчанию сортируем по убыванию
983            }
984            
985            this.updateTableContent();
986        }
987
988        updateFilterButtons() {
989            const filterSearchQuery = this.filterSearchQuery || '';
990            const filterSentimentType = this.filterSentimentType || 'all';
991            const filterButtons = document.querySelectorAll('.filter-btn');
992            
993            filterButtons.forEach(btn => {
994                const filterText = btn.getAttribute('data-filter').toLowerCase();
995                const sentiment = btn.getAttribute('data-sentiment');
996                const isSelected = this.selectedFilters.has(btn.getAttribute('data-filter'));
997                
998                // Проверяем соответствие поисковому запросу
999                const matchesSearch = filterSearchQuery === '' || filterText.includes(filterSearchQuery);
1000                
1001                // Проверяем соответствие типу фильтра (позитивный/негативный/все)
1002                const matchesSentiment = filterSentimentType === 'all' || sentiment === filterSentimentType;
1003                
1004                // Показываем кнопку только если она соответствует обоим условиям
1005                if (matchesSearch && matchesSentiment) {
1006                    btn.style.display = 'inline-block';
1007                } else {
1008                    btn.style.display = 'none';
1009                }
1010                
1011                // Обновляем стили выбранных кнопок
1012                const bgColor = sentiment === 'positive' ? '#28a745' : '#dc3545';
1013                const selectedBgColor = sentiment === 'positive' ? '#1e7e34' : '#bd2130';
1014                
1015                btn.style.background = isSelected ? selectedBgColor : bgColor;
1016                btn.style.fontWeight = isSelected ? '600' : '400';
1017                btn.style.opacity = isSelected ? '1' : '0.85';
1018                btn.style.boxShadow = isSelected ? '0 2px 8px rgba(0, 0, 0, 0.2)' : 'none';
1019            });
1020        }
1021
1022        updateTableContent() {
1023            const tbody = document.getElementById('opinions-table-body');
1024            if (tbody) {
1025                tbody.innerHTML = this.renderTableRows();
1026            }
1027        }
1028
1029        renderTableRows() {
1030            let filteredOpinions = this.opinions;
1031
1032            // Применяем поиск по SKU/названию
1033            if (this.searchQuery) {
1034                filteredOpinions = filteredOpinions.filter(item => 
1035                    item.sku.toLowerCase().includes(this.searchQuery) ||
1036                    item.name.toLowerCase().includes(this.searchQuery)
1037                );
1038            }
1039
1040            // Применяем фильтры по мнениям
1041            if (this.selectedFilters.size > 0) {
1042                if (this.filterLogic === 'AND') {
1043                    // Логика И: товар должен содержать ВСЕ выбранные мнения
1044                    filteredOpinions = filteredOpinions.filter(item => {
1045                        const itemOpinions = new Set(item.opinions.map(op => op.text));
1046                        return Array.from(this.selectedFilters).every(filter => itemOpinions.has(filter));
1047                    });
1048                } else {
1049                    // Логика ИЛИ: товар должен содержать ХОТЯ БЫ ОДНО из выбранных мнений
1050                    filteredOpinions = filteredOpinions.filter(item => 
1051                        item.opinions.some(op => this.selectedFilters.has(op.text))
1052                    );
1053                }
1054            }
1055
1056            // Применяем сортировку
1057            if (this.sortColumn) {
1058                filteredOpinions = [...filteredOpinions].sort((a, b) => {
1059                    let aValue, bValue;
1060                    
1061                    switch (this.sortColumn) {
1062                    case 'sku':
1063                        aValue = a.sku;
1064                        bValue = b.sku;
1065                        break;
1066                    case 'name':
1067                        aValue = a.name.toLowerCase();
1068                        bValue = b.name.toLowerCase();
1069                        break;
1070                    case 'reviews':
1071                        aValue = a.reviewCount;
1072                        bValue = b.reviewCount;
1073                        break;
1074                    case 'positive':
1075                        // Считаем сумму процентов позитивных мнений
1076                        aValue = a.opinions.filter(op => op.sentiment === 'positive').reduce((sum, op) => sum + op.percentage, 0);
1077                        bValue = b.opinions.filter(op => op.sentiment === 'positive').reduce((sum, op) => sum + op.percentage, 0);
1078                        break;
1079                    case 'negative':
1080                        // Считаем сумму процентов негативных мнений
1081                        aValue = a.opinions.filter(op => op.sentiment === 'negative').reduce((sum, op) => sum + op.percentage, 0);
1082                        bValue = b.opinions.filter(op => op.sentiment === 'negative').reduce((sum, op) => sum + op.percentage, 0);
1083                        break;
1084                    }
1085                    
1086                    if (this.sortDirection === 'asc') {
1087                        return aValue > bValue ? 1 : aValue < bValue ? -1 : 0;
1088                    } else {
1089                        return aValue < bValue ? 1 : aValue > bValue ? -1 : 0;
1090                    }
1091                });
1092            }
1093
1094            if (filteredOpinions.length === 0) {
1095                return `
1096                    <tr>
1097                        <td colspan="5" style="padding: 48px; text-align: center; color: #999;">
1098                            ${this.searchQuery ? 'Ничего не найдено по запросу "' + this.searchQuery + '"' : 
1099        this.selectedFilters.size === 0 ? 'Нет собранных данных. Нажмите "Собрать" для начала сбора.' : 
1100            'Нет товаров, соответствующих выбранным фильтрам.'}
1101                        </td>
1102                    </tr>
1103                `;
1104            }
1105
1106            return filteredOpinions.map(item => {
1107                // Подсчитываем статистику по тональности (сумма процентов)
1108                const positiveOpinions = item.opinions.filter(op => op.sentiment === 'positive');
1109                const negativeOpinions = item.opinions.filter(op => op.sentiment === 'negative');
1110                
1111                const positivePercent = positiveOpinions.reduce((sum, op) => sum + op.percentage, 0);
1112                const negativePercent = negativeOpinions.reduce((sum, op) => sum + op.percentage, 0);
1113                
1114                // Фильтруем мнения по выбранной тональности
1115                let displayOpinions = item.opinions;
1116                const sentimentFilter = this.sentimentFilter || 'all';
1117                if (sentimentFilter === 'positive') {
1118                    displayOpinions = positiveOpinions;
1119                } else if (sentimentFilter === 'negative') {
1120                    displayOpinions = negativeOpinions;
1121                }
1122                
1123                return `
1124                <tr style="border-bottom: 1px solid #f0f0f0;">
1125                    <td style="padding: 16px; color: #666; font-family: monospace; font-size: 13px;">${item.sku}</td>
1126                    <td style="padding: 16px; color: #333;">${item.name}</td>
1127                    <td style="padding: 16px; text-align: center; color: #005bff; font-weight: 600;">${item.reviewCount}</td>
1128                    <td style="padding: 16px;">
1129                        <div style="display: flex; flex-direction: column; gap: 6px; align-items: center;">
1130                            <div style="display: flex; align-items: center; gap: 8px;">
1131                                <span style="
1132                                    background: #28a745;
1133                                    color: white;
1134                                    padding: 4px 10px;
1135                                    border-radius: 12px;
1136                                    font-size: 12px;
1137                                    font-weight: 600;
1138                                ">🟢 ${positiveOpinions.length} (${Math.round(positivePercent)}%)</span>
1139                            </div>
1140                            <div style="display: flex; align-items: center; gap: 8px;">
1141                                <span style="
1142                                    background: #dc3545;
1143                                    color: white;
1144                                    padding: 4px 10px;
1145                                    border-radius: 12px;
1146                                    font-size: 12px;
1147                                    font-weight: 600;
1148                                ">🔴 ${negativeOpinions.length} (${Math.round(negativePercent)}%)</span>
1149                            </div>
1150                        </div>
1151                    </td>
1152                    <td style="padding: 16px;">
1153                        <div style="display: flex; flex-direction: column; gap: 6px;">
1154                            ${displayOpinions.map(op => {
1155        const isHighlighted = this.selectedFilters.has(op.text);
1156        const sentiment = op.sentiment || 'positive';
1157        const bgColor = sentiment === 'positive' ? '#d4edda' : '#f8d7da';
1158        const borderColor = sentiment === 'positive' ? '#28a745' : '#dc3545';
1159        const badgeColor = sentiment === 'positive' ? '#28a745' : '#dc3545';
1160                                
1161        return `
1162                                    <div style="
1163                                        display: flex;
1164                                        align-items: center;
1165                                        gap: 8px;
1166                                        padding: 6px 12px;
1167                                        background: ${isHighlighted ? bgColor : '#f8f9fa'};
1168                                        border-radius: 6px;
1169                                        font-size: 13px;
1170                                        border: ${isHighlighted ? '2px solid ' + borderColor : 'none'};
1171                                    ">
1172                                        <span style="flex: 1; color: #333; font-weight: ${isHighlighted ? '600' : '400'};">${op.text}</span>
1173                                        <span style="
1174                                            background: ${badgeColor};
1175                                            color: white;
1176                                            padding: 2px 8px;
1177                                            border-radius: 12px;
1178                                            font-size: 12px;
1179                                            font-weight: 600;
1180                                        ">${op.percentage}%</span>
1181                                    </div>
1182                                `;
1183    }).join('')}
1184                        </div>
1185                    </td>
1186                </tr>
1187            `;
1188            }).join('');
1189        }
1190
1191        applyFilter(filter) {
1192            const tbody = document.getElementById('opinions-table-body');
1193            if (tbody) {
1194                tbody.innerHTML = this.renderTableRows(filter);
1195            }
1196        }
1197
1198        closeTable() {
1199            if (this.tableModal) {
1200                this.tableModal.remove();
1201                this.tableModal = null;
1202            }
1203        }
1204
1205        sleep(ms) {
1206            return new Promise(resolve => setTimeout(resolve, ms));
1207        }
1208    }
1209
1210    // Инициализация при загрузке страницы
1211    if (document.readyState === 'loading') {
1212        document.addEventListener('DOMContentLoaded', () => {
1213            const collector = new OpinionsCollector();
1214            collector.init();
1215        });
1216    } else {
1217        const collector = new OpinionsCollector();
1218        collector.init();
1219    }
1220
1221})();