Автоматическое выделение и удаление площадок по заданным маскам
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})();