Yandex Direct - Удаление площадок по маскам

Автоматическое выделение и удаление площадок по заданным маскам

Size

21.3 KB

Version

1.2.9

Created

Apr 1, 2026

Updated

16 days ago

1// ==UserScript==
2// @name		Yandex Direct - Удаление площадок по маскам
3// @description		Автоматическое выделение и удаление площадок по заданным маскам
4// @version		1.2.9
5// @match		https://*.direct.yandex.ru/*
6// @icon		https://direct.yastatic.net/s3/direct-frontend/uac/desktop/assets/b7b733df183b603b.ico
7// ==/UserScript==
8(function() {
9    'use strict';
10
11    console.log('Yandex Direct - Удаление площадок по маскам: расширение запущено');
12
13    // Дебаунс функция для оптимизации
14    function debounce(func, wait) {
15        let timeout;
16        return function executedFunction(...args) {
17            const later = () => {
18                clearTimeout(timeout);
19                func(...args);
20            };
21            clearTimeout(timeout);
22            timeout = setTimeout(later, wait);
23        };
24    }
25
26    // Функция для создания модального окна
27    function createModal() {
28        const modal = document.createElement('div');
29        modal.id = 'platform-remover-modal';
30        modal.style.cssText = `
31            position: fixed;
32            top: 0;
33            left: 0;
34            width: 100%;
35            height: 100%;
36            background: rgba(0, 0, 0, 0.5);
37            display: flex;
38            justify-content: center;
39            align-items: center;
40            z-index: 10000;
41        `;
42
43        const modalContent = document.createElement('div');
44        modalContent.style.cssText = `
45            background: white;
46            padding: 30px;
47            border-radius: 12px;
48            box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
49            max-width: 500px;
50            width: 90%;
51        `;
52
53        const title = document.createElement('h2');
54        title.textContent = 'Удаление площадок по маскам';
55        title.style.cssText = `
56            margin: 0 0 20px 0;
57            font-size: 20px;
58            color: #333;
59        `;
60
61        const description = document.createElement('p');
62        description.textContent = 'Введите маски для удаления (каждая с новой строки):';
63        description.style.cssText = `
64            margin: 0 0 10px 0;
65            font-size: 14px;
66            color: #666;
67        `;
68
69        const textarea = document.createElement('textarea');
70        textarea.id = 'platform-masks-input';
71        textarea.value = 'dsp\ncom.\ngame\nfree\nvpn';
72        textarea.style.cssText = `
73            width: 100%;
74            height: 120px;
75            padding: 10px;
76            border: 1px solid #ddd;
77            border-radius: 6px;
78            font-size: 14px;
79            font-family: monospace;
80            resize: vertical;
81            box-sizing: border-box;
82        `;
83
84        const whitelistDescription = document.createElement('p');
85        whitelistDescription.textContent = 'Исключения (не удалять, каждое с новой строки):';
86        whitelistDescription.style.cssText = `
87            margin: 15px 0 10px 0;
88            font-size: 14px;
89            color: #666;
90        `;
91
92        const whitelistTextarea = document.createElement('textarea');
93        whitelistTextarea.id = 'platform-whitelist-input';
94        whitelistTextarea.value = 'avito\nvk\nmail\nvkontakte\nok';
95        whitelistTextarea.style.cssText = `
96            width: 100%;
97            height: 100px;
98            padding: 10px;
99            border: 1px solid #ddd;
100            border-radius: 6px;
101            font-size: 14px;
102            font-family: monospace;
103            resize: vertical;
104            box-sizing: border-box;
105        `;
106
107        const statusDiv = document.createElement('div');
108        statusDiv.id = 'platform-remover-status';
109        statusDiv.style.cssText = `
110            margin: 15px 0;
111            padding: 10px;
112            border-radius: 6px;
113            font-size: 14px;
114            display: none;
115        `;
116
117        const buttonsDiv = document.createElement('div');
118        buttonsDiv.style.cssText = `
119            display: flex;
120            gap: 10px;
121            margin-top: 20px;
122        `;
123
124        const startButton = document.createElement('button');
125        startButton.textContent = 'Запустить';
126        startButton.style.cssText = `
127            flex: 1;
128            padding: 12px 24px;
129            background: #fc0;
130            color: #000;
131            border: none;
132            border-radius: 6px;
133            font-size: 14px;
134            font-weight: 600;
135            cursor: pointer;
136            transition: background 0.2s;
137        `;
138        startButton.onmouseover = () => startButton.style.background = '#ffdb4d';
139        startButton.onmouseout = () => startButton.style.background = '#fc0';
140
141        const stopButton = document.createElement('button');
142        stopButton.textContent = 'Стоп';
143        stopButton.style.cssText = `
144            flex: 1;
145            padding: 12px 24px;
146            background: #ff4444;
147            color: #fff;
148            border: none;
149            border-radius: 6px;
150            font-size: 14px;
151            font-weight: 600;
152            cursor: pointer;
153            transition: background 0.2s;
154            display: none;
155        `;
156        stopButton.onmouseover = () => stopButton.style.background = '#ff6666';
157        stopButton.onmouseout = () => stopButton.style.background = '#ff4444';
158
159        const cancelButton = document.createElement('button');
160        cancelButton.textContent = 'Отмена';
161        cancelButton.style.cssText = `
162            flex: 1;
163            padding: 12px 24px;
164            background: #f0f0f0;
165            color: #333;
166            border: none;
167            border-radius: 6px;
168            font-size: 14px;
169            font-weight: 600;
170            cursor: pointer;
171            transition: background 0.2s;
172        `;
173        cancelButton.onmouseover = () => cancelButton.style.background = '#e0e0e0';
174        cancelButton.onmouseout = () => cancelButton.style.background = '#f0f0f0';
175
176        startButton.onclick = () => startProcessing(textarea.value, statusDiv, startButton, stopButton, cancelButton, whitelistTextarea.value);
177        cancelButton.onclick = () => modal.remove();
178
179        buttonsDiv.appendChild(startButton);
180        buttonsDiv.appendChild(stopButton);
181        buttonsDiv.appendChild(cancelButton);
182
183        modalContent.appendChild(title);
184        modalContent.appendChild(description);
185        modalContent.appendChild(textarea);
186        modalContent.appendChild(whitelistDescription);
187        modalContent.appendChild(whitelistTextarea);
188        modalContent.appendChild(statusDiv);
189        modalContent.appendChild(buttonsDiv);
190
191        modal.appendChild(modalContent);
192        document.body.appendChild(modal);
193
194        console.log('Модальное окно создано');
195    }
196
197    // Функция для обновления статуса
198    function updateStatus(statusDiv, message, type = 'info') {
199        statusDiv.style.display = 'block';
200        statusDiv.textContent = message;
201        
202        if (type === 'success') {
203            statusDiv.style.background = '#d4edda';
204            statusDiv.style.color = '#155724';
205            statusDiv.style.border = '1px solid #c3e6cb';
206        } else if (type === 'error') {
207            statusDiv.style.background = '#f8d7da';
208            statusDiv.style.color = '#721c24';
209            statusDiv.style.border = '1px solid #f5c6cb';
210        } else {
211            statusDiv.style.background = '#d1ecf1';
212            statusDiv.style.color = '#0c5460';
213            statusDiv.style.border = '1px solid #bee5eb';
214        }
215        
216        console.log(`Статус: ${message}`);
217    }
218
219    // Функция для получения всех строк таблицы
220    function getTableRows() {
221        return document.querySelectorAll('[data-testid^="Grid.Row"]');
222    }
223
224    // Функция для получения названия площадки из строки
225    function getPlatformName(row) {
226        const cells = row.querySelectorAll('[data-testid^="Grid.Cell"]');
227        if (cells.length > 1) {
228            return cells[1].textContent.trim();
229        }
230        return '';
231    }
232
233    // Функция для получения расхода из строки
234    function getPlatformCost(row) {
235        const cells = row.querySelectorAll('[data-testid^="Grid.Cell"]');
236        if (cells.length > 4) {
237            const costText = cells[4].textContent.trim();
238            // Убираем пробелы, символ рубля и запятую, заменяем на точку
239            const costNumber = parseFloat(costText.replace(/\s/g, '').replace('₽', '').replace(',', '.'));
240            return isNaN(costNumber) ? 0 : costNumber;
241        }
242        return 0;
243    }
244
245    // Функция для получения общего расхода из строки "Итого"
246    function getTotalCost() {
247        const totalHeader = document.querySelector('[data-testid="TotalSubHeader"]');
248        if (totalHeader) {
249            // Поднимемся до родителя с Grid.HeaderCell
250            const headerCell = totalHeader.closest('[data-testid^="Grid.HeaderCell"]');
251            if (headerCell) {
252                // Найдем родительский контейнер всех HeaderCell
253                const headerRow = headerCell.parentElement;
254                if (headerRow) {
255                    // Найдем все HeaderCell в этой строке
256                    const allHeaderCells = headerRow.querySelectorAll('[data-testid^="Grid.HeaderCell"]');
257                    // Ячейка с расходом - это 5-я ячейка (индекс 4)
258                    if (allHeaderCells.length > 4) {
259                        const costText = allHeaderCells[4].textContent.trim();
260                        const costNumber = parseFloat(costText.replace(/\s/g, '').replace('₽', '').replace(',', '.'));
261                        console.log('Общий расход из "Итого":', costNumber);
262                        return isNaN(costNumber) ? 0 : costNumber;
263                    }
264                }
265            }
266        }
267        console.log('Строка "Итого" не найдена');
268        return 0;
269    }
270
271    // Функция для клика по чекбоксу в строке
272    function clickCheckbox(row) {
273        // Сначала проверяем, не отмечен ли уже чекбокс
274        const checkedBox = row.querySelector('[data-testid*="Checkbox"][data-checked="true"]');
275        if (checkedBox) {
276            console.log('Чекбокс уже отмечен');
277            return false;
278        }
279        
280        // Ищем label чекбокса для клика
281        const checkboxLabel = row.querySelector('[data-testid*="Checkbox.label"]');
282        if (checkboxLabel) {
283            checkboxLabel.click();
284            console.log('Клик по чекбоксу выполнен');
285            return true;
286        }
287        
288        console.log('Чекбокс не найден в строке');
289        return false;
290    }
291
292    // Функция для скроллинга таблицы
293    function scrollTable() {
294        // Ищем контейнер со скроллом - это div.dc-Scrollbar
295        const scrollContainer = document.querySelector('[data-testid="StatisticsReportTable"] .dc-Scrollbar');
296        
297        if (scrollContainer) {
298            scrollContainer.scrollTop += 300;
299            console.log('Скролл выполнен, scrollTop:', scrollContainer.scrollTop);
300            return scrollContainer.scrollTop;
301        }
302        
303        console.log('Контейнер для скролла не найден');
304        return 0;
305    }
306
307    // Функция для проверки, достигнут ли конец таблицы
308    function isScrollAtBottom() {
309        const scrollContainer = document.querySelector('[data-testid="StatisticsReportTable"] .dc-Scrollbar');
310        
311        if (scrollContainer) {
312            const isAtBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop <= scrollContainer.clientHeight + 100;
313            console.log('Проверка конца таблицы:', isAtBottom, 'scrollHeight:', scrollContainer.scrollHeight, 'scrollTop:', scrollContainer.scrollTop, 'clientHeight:', scrollContainer.clientHeight);
314            return isAtBottom;
315        }
316        return false;
317    }
318
319    // Основная функция обработки
320    async function startProcessing(masksText, statusDiv, startButton, stopButton, cancelButton, whitelistText) {
321        const masks = masksText.split('\n').map(m => m.trim()).filter(m => m.length > 0);
322        const whitelist = whitelistText.split('\n').map(m => m.trim()).filter(m => m.length > 0);
323        
324        if (masks.length === 0) {
325            updateStatus(statusDiv, 'Ошибка: не указаны маски', 'error');
326            return;
327        }
328
329        console.log('Начинаем обработку с масками:', masks);
330        console.log('Белый список (исключения):', whitelist);
331        
332        // Переключаем кнопки
333        startButton.style.display = 'none';
334        stopButton.style.display = 'block';
335        cancelButton.disabled = true;
336        cancelButton.style.opacity = '0.5';
337        cancelButton.style.cursor = 'not-allowed';
338
339        updateStatus(statusDiv, 'Начинаем обработку...', 'info');
340
341        let processedCount = 0;
342        let selectedCount = 0;
343        let selectedCost = 0;
344        let previousScrollTop = -1;
345        let sameScrollIterations = 0;
346        let processLoop = null;
347
348        // Функция остановки
349        const stopProcessing = () => {
350            if (processLoop) {
351                clearInterval(processLoop);
352                processLoop = null;
353            }
354            
355            updateStatus(statusDiv, `⏸ Остановлено. Обработано: ${processedCount}, выбрано: ${selectedCount}`, 'info');
356            console.log('Обработка остановлена пользователем');
357            
358            // Возвращаем кнопки в исходное состояние
359            stopButton.style.display = 'none';
360            cancelButton.disabled = false;
361            cancelButton.style.opacity = '1';
362            cancelButton.style.cursor = 'pointer';
363            cancelButton.textContent = 'Закрыть';
364        };
365
366        stopButton.onclick = stopProcessing;
367
368        // Функция для обработки видимых строк
369        const processVisibleRows = () => {
370            const rows = getTableRows();
371            console.log(`Обрабатываем ${rows.length} строк`);
372            
373            rows.forEach(row => {
374                const platformName = getPlatformName(row);
375                if (!platformName) return;
376
377                // Проверяем, не входит ли площадка в белый список
378                const isWhitelisted = whitelist.some(item => platformName.includes(item));
379                if (isWhitelisted) {
380                    console.log(`Площадка в белом списке, пропускаем: ${platformName}`);
381                    processedCount++;
382                    return;
383                }
384
385                // Проверяем, содержит ли название хотя бы одну маску
386                const matchesMask = masks.some(mask => platformName.includes(mask));
387                
388                if (matchesMask) {
389                    const clicked = clickCheckbox(row);
390                    if (clicked) {
391                        const cost = getPlatformCost(row);
392                        selectedCost += cost;
393                        selectedCount++;
394                        console.log(`Выбрана площадка: ${platformName}, расход: ${cost.toFixed(2)}`);
395                    }
396                }
397                processedCount++;
398            });
399
400            // Получаем общий расход
401            const totalCost = getTotalCost();
402            const savingsPercent = totalCost > 0 ? ((selectedCost / totalCost) * 100).toFixed(2) : 0;
403            
404            const statusMessage = `Обработано: ${processedCount}, выбрано: ${selectedCount}\n` +
405                                 `Расход выбранных: ${selectedCost.toFixed(2)} ₽\n` +
406                                 `Общий расход: ${totalCost.toFixed(2)} ₽\n` +
407                                 `Экономия: ${savingsPercent}%`;
408            
409            updateStatus(statusDiv, statusMessage, 'info');
410            
411            return { selectedCost, totalCost, savingsPercent };
412        };
413
414        // Основной цикл скроллинга и обработки
415        processLoop = setInterval(() => {
416            const stats = processVisibleRows();
417            
418            const currentScrollTop = scrollTable();
419            
420            // Проверяем, изменилась ли позиция скролла
421            if (currentScrollTop === previousScrollTop) {
422                sameScrollIterations++;
423                console.log(`Скролл не изменился, попытка ${sameScrollIterations} из 5`);
424            } else {
425                sameScrollIterations = 0;
426                previousScrollTop = currentScrollTop;
427            }
428
429            // Если достигли конца или застряли на одном месте 5 раз
430            if (isScrollAtBottom() || sameScrollIterations >= 5) {
431                clearInterval(processLoop);
432                
433                // Финальная обработка
434                const finalStats = processVisibleRows();
435                
436                const finalMessage = '✓ Все площадки выделены!\n' +
437                                    `Обработано: ${processedCount}, выбрано: ${selectedCount}\n` +
438                                    `Расход выбранных: ${finalStats.selectedCost.toFixed(2)} ₽\n` +
439                                    `Общий расход: ${finalStats.totalCost.toFixed(2)} ₽\n` +
440                                    `💰 Экономия: ${finalStats.savingsPercent}%`;
441                
442                updateStatus(statusDiv, finalMessage, 'success');
443                console.log('Обработка завершена');
444                
445                // Скрываем кнопку стоп и включаем кнопку закрытия
446                stopButton.style.display = 'none';
447                cancelButton.disabled = false;
448                cancelButton.style.opacity = '1';
449                cancelButton.style.cursor = 'pointer';
450                cancelButton.textContent = 'Закрыть';
451            }
452        }, 500);
453    }
454
455    // Функция для создания кнопки "Удалить площадки"
456    function createMainButton() {
457        const pageHead = document.querySelector('[data-testid="ReportsWizardLoginPage.PageHead"]');
458        if (!pageHead) {
459            console.log('PageHead не найден, повторим попытку позже');
460            return false;
461        }
462
463        // Проверяем, не создана ли уже кнопка
464        if (document.getElementById('platform-remover-button')) {
465            console.log('Кнопка уже существует');
466            return true;
467        }
468
469        const titleContainer = pageHead.querySelector('.dc-Stack.dc-Stack_type_horizontal.dc-Stack_gap_8');
470        if (!titleContainer) {
471            console.log('Контейнер заголовка не найден');
472            return false;
473        }
474
475        const button = document.createElement('button');
476        button.id = 'platform-remover-button';
477        button.textContent = 'Удалить площадки';
478        button.style.cssText = `
479            padding: 8px 16px;
480            background: #fc0;
481            color: #000;
482            border: none;
483            border-radius: 6px;
484            font-size: 14px;
485            font-weight: 600;
486            cursor: pointer;
487            transition: background 0.2s;
488            margin-left: 12px;
489        `;
490        button.onmouseover = () => button.style.background = '#ffdb4d';
491        button.onmouseout = () => button.style.background = '#fc0';
492        button.onclick = createModal;
493
494        titleContainer.appendChild(button);
495        console.log('Кнопка "Удалить площадки" добавлена');
496        return true;
497    }
498
499    // Инициализация
500    function init() {
501        console.log('Инициализация расширения');
502        
503        // Пробуем создать кнопку сразу
504        if (!createMainButton()) {
505            // Если не получилось, ждем загрузки страницы
506            const observer = new MutationObserver(debounce(() => {
507                if (createMainButton()) {
508                    observer.disconnect();
509                }
510            }, 500));
511
512            observer.observe(document.body, {
513                childList: true,
514                subtree: true
515            });
516        }
517    }
518
519    // Запускаем после загрузки DOM
520    if (document.readyState === 'loading') {
521        document.addEventListener('DOMContentLoaded', init);
522    } else {
523        init();
524    }
525})();