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})();