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