|
/*! |
|
* VK Music Cleaner — удаляет всю музыку из раздела «Моя музыка» ВКонтакте. |
|
* |
|
* Возможности: |
|
* • Без перезагрузок — работает в одной вкладке до конца. |
|
* • Прямой выз onClick из React Fiber (надёжнее dispatchEvent). |
|
* • Плавная адаптация скорости (−1/+1, а не /2/*2). |
|
* • Авто-поиск правильного скролл-контейнера (виртуализация ВК). |
|
* • Перехват bad_hash из fetch + XHR с экспоненциальной паузой. |
|
* • Skip-list: «залипшие» кнопки пропускаются после 2 попыток. |
|
* • UI-оверлей с прогрессом, скоростью и ETA. |
|
* • Горячие клавиши: Ctrl+Shift+P (пауза), Ctrl+Shift+R (продолжить). |
|
* • Защита от случайного закрытия вкладки (beforeunload). |
|
* • Тихий режим (каждые 50 треков) + флаг verbose для отладки. |
|
* • Статистика сессии сохраняется в localStorage. |
|
* |
|
* Лицензия: MIT. Используйте на свой риск — это автоматизация |
|
* пользовательского интерфейса. Автор не несёт ответственности |
|
* за последствия, включая возможные ограничения со стороны ВК. |
|
* |
|
* Использование: |
|
* 1. Откройте https://vk.com/audio (раздел «Моя музыка»). |
|
* 2. F12 → Console. |
|
* 3. Вставьте этот код целиком → Enter. |
|
* 4. Не закрывайте вкладку. Можно свернуть. |
|
* |
|
* Управление: |
|
* Пауза / продолжить — кнопки в оверлее (правый верхний угол). |
|
* Ctrl+Shift+P — пауза. |
|
* Ctrl+Shift+R — продолжить. |
|
* Ctrl+Shift+S — полная остановка + скрыть оверлей. |
|
* |
|
* Сброс состояния (в консоли): |
|
* localStorage.removeItem('vk_music_deleted') |
|
* localStorage.removeItem('vk_music_running') |
|
* localStorage.removeItem('vk_music_paused') |
|
* |
|
* Скорость (поменять в объекте CONFIG): |
|
* 'safe' — батч 3, пауза 2000 мс (максимально надёжно) |
|
* 'normal' — батч 5, пауза 1500 мс (по умолчанию) |
|
* 'turbo' — батч 8, пауза 1000 мс |
|
* 'extreme' — батч 12, пауза 700 мс (риск bad_hash) |
|
* |
|
* Версия: 7.0 |
|
*/ |
|
|
|
(async function VKMusicCleaner() { |
|
'use strict'; |
|
|
|
// ==================== КОНФИГУРАЦИЯ ==================== |
|
const CONFIG = { |
|
speed: 'normal', // 'safe' | 'normal' | 'turbo' | 'extreme' |
|
verbose: false, // true — логировать каждый батч; false — каждые 50 треков |
|
protectUnload: true, // предупреждать при закрытии вкладки |
|
hoverDelay: 30, // пауза после hover для активации кнопки (мс) |
|
scrollStep: 800, // шаг скролла (px) |
|
waitForRemoveMs: 5000, // ожидание исчезновения трека (мс) |
|
stuckThreshold: 2, // сколько попыток до skip |
|
emptyPauseMs: 5000, // пауза, если долго нет кнопок |
|
}; |
|
|
|
const PROFILES = { |
|
safe: { batchSize: 3, batchPause: 2000, scrollPause: 600 }, |
|
normal: { batchSize: 5, batchPause: 1500, scrollPause: 500 }, |
|
turbo: { batchSize: 8, batchPause: 1000, scrollPause: 400 }, |
|
extreme: { batchSize: 12, batchPause: 700, scrollPause: 300 }, |
|
}; |
|
const P = PROFILES[CONFIG.speed] || PROFILES.normal; |
|
|
|
// ==================== ХРАНИЛИЩЕ ==================== |
|
const K = { |
|
deleted: 'vk_music_deleted', |
|
running: 'vk_music_running', |
|
paused: 'vk_music_paused', |
|
lastRun: 'vk_music_last_run', |
|
stats: 'vk_music_stats', |
|
}; |
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
|
const get = (k, d) => { const v = localStorage.getItem(k); return v === null ? d : v; }; |
|
const set = (k, v) => localStorage.setItem(k, v); |
|
const getNum = (k, d) => parseInt(get(k, d) || '0', 10); |
|
const isRunning = () => get(K.running, '0') !== '0'; |
|
const setRunning = (v) => set(K.running, v ? '1' : '0'); |
|
const isPaused = () => get(K.paused, '0') === '1'; |
|
const setPaused = (v) => set(K.paused, v ? '1' : '0'); |
|
|
|
// Защита от повторного запуска. |
|
const lastRun = getNum(K.lastRun, 0); |
|
if (isRunning() && Date.now() - lastRun < 30_000) { |
|
console.log('%c[VKMusicCleaner] Уже запущен. Полная остановка: localStorage.setItem("' + K.running + '","0")', 'color:#ff9800;font-weight:bold'); |
|
return; |
|
} |
|
setRunning(true); |
|
setPaused(false); |
|
set(K.lastRun, String(Date.now())); |
|
|
|
// ==================== СОСТОЯНИЕ ==================== |
|
let deleted = getNum(K.deleted, 0); |
|
let sessionDeleted = 0; |
|
let consecutiveEmpty = 0; |
|
let consecutiveErrors = 0; |
|
let consecutiveSuccess = 0; |
|
let currentBatchSize = P.batchSize; |
|
let currentBatchPause = P.batchPause; |
|
let badHashDetected = false; |
|
let stuckSet = new Set(); |
|
let startTime = Date.now(); |
|
let paused = false; |
|
|
|
// Статистика для ETA. |
|
const stats = JSON.parse(get(K.stats, '[]')); |
|
function pushStat() { |
|
stats.push({ t: Date.now(), n: deleted }); |
|
if (stats.length > 200) stats.shift(); |
|
set(K.stats, JSON.stringify(stats)); |
|
} |
|
|
|
// ==================== ЛОГ ==================== |
|
function log(msg, color = '#4aa3ff', force = false) { |
|
if (!CONFIG.verbose && !force && !msg.includes('bad_hash') && !msg.includes('Готово') && !msg.includes('Старт')) { |
|
// Тихий режим: пишем только milestones. |
|
if (sessionDeleted % 50 !== 0 && sessionDeleted !== 0) return; |
|
} |
|
const ts = new Date().toLocaleTimeString(); |
|
console.log(`%c[${ts}] [VKMC/${CONFIG.speed}] ${msg}`, `color:${color}`); |
|
} |
|
|
|
// ==================== UI ОВЕРЛЕЙ ==================== |
|
const overlay = document.createElement('div'); |
|
overlay.id = 'vkmc-overlay'; |
|
overlay.style.cssText = ` |
|
position: fixed; top: 16px; right: 16px; z-index: 2147483647; |
|
background: linear-gradient(135deg, #1e293b, #0f172a); |
|
color: #e2e8f0; padding: 14px 18px; border-radius: 12px; |
|
font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace; |
|
box-shadow: 0 10px 40px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.08); |
|
min-width: 240px; cursor: move; user-select: none; |
|
backdrop-filter: blur(10px); |
|
`; |
|
overlay.innerHTML = ` |
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;"> |
|
<div style="width:10px;height:10px;border-radius:50%;background:#22c55e;box-shadow:0 0 10px #22c55e;" id="vkmc-dot"></div> |
|
<strong style="font-size:14px;">VK Music Cleaner</strong> |
|
</div> |
|
<div id="vkmc-status" style="color:#94a3b8;font-size:11px;margin-bottom:8px;">работает…</div> |
|
<div style="display:grid;grid-template-columns:auto 1fr;gap:4px 12px;font-size:12px;"> |
|
<span style="color:#64748b;">Удалено:</span><span id="vkmc-deleted" style="color:#22c55e;font-weight:bold;">0</span> |
|
<span style="color:#64748b;">Сессия:</span><span id="vkmc-session">0</span> |
|
<span style="color:#64748b;">Скорость:</span><span id="vkmc-speed">—</span> |
|
<span style="color:#64748b;">ETA:</span><span id="vkmc-eta">—</span> |
|
<span style="color:#64748b;">Батч:</span><span id="vkmc-batch">—</span> |
|
</div> |
|
<div style="display:flex;gap:6px;margin-top:10px;"> |
|
<button id="vkmc-pause" style="flex:1;padding:6px;border:0;border-radius:6px;background:#f59e0b;color:#000;font-weight:600;cursor:pointer;font-size:11px;">⏸ Пауза</button> |
|
<button id="vkmc-stop" style="flex:1;padding:6px;border:0;border-radius:6px;background:#ef4444;color:#fff;font-weight:600;cursor:pointer;font-size:11px;">⏹ Стоп</button> |
|
</div> |
|
<div style="margin-top:8px;font-size:10px;color:#475569;">Ctrl+Shift+P — пауза · Ctrl+Shift+R — resume</div> |
|
`; |
|
document.documentElement.appendChild(overlay); |
|
|
|
// Drag&drop оверлея. |
|
let dragOff = null; |
|
overlay.addEventListener('mousedown', (e) => { |
|
if (e.target.tagName === 'BUTTON') return; |
|
const r = overlay.getBoundingClientRect(); |
|
dragOff = { x: e.clientX - r.left, y: e.clientY - r.top }; |
|
overlay.style.cursor = 'grabbing'; |
|
}); |
|
document.addEventListener('mousemove', (e) => { |
|
if (!dragOff) return; |
|
overlay.style.left = (e.clientX - dragOff.x) + 'px'; |
|
overlay.style.top = (e.clientY - dragOff.y) + 'px'; |
|
overlay.style.right = 'auto'; |
|
}); |
|
document.addEventListener('mouseup', () => { |
|
dragOff = null; |
|
overlay.style.cursor = 'move'; |
|
}); |
|
|
|
function updateUI() { |
|
const dot = overlay.querySelector('#vkmc-dot'); |
|
const status = overlay.querySelector('#vkmc-status'); |
|
if (paused) { |
|
dot.style.background = '#f59e0b'; |
|
dot.style.boxShadow = '0 0 10px #f59e0b'; |
|
status.textContent = 'пауза'; |
|
} else if (badHashDetected) { |
|
dot.style.background = '#ef4444'; |
|
dot.style.boxShadow = '0 0 10px #ef4444'; |
|
status.textContent = 'замедление (bad_hash)'; |
|
} else { |
|
dot.style.background = '#22c55e'; |
|
dot.style.boxShadow = '0 0 10px #22c55e'; |
|
status.textContent = 'работает…'; |
|
} |
|
overlay.querySelector('#vkmc-deleted').textContent = deleted; |
|
overlay.querySelector('#vkmc-session').textContent = sessionDeleted; |
|
overlay.querySelector('#vkmc-batch').textContent = `${currentBatchSize} / ${currentBatchPause}мс`; |
|
|
|
const elapsed = (Date.now() - startTime) / 1000; |
|
const rate = sessionDeleted > 0 && elapsed > 0 ? (sessionDeleted / elapsed) : 0; |
|
overlay.querySelector('#vkmc-speed').textContent = rate > 0 ? `${rate.toFixed(1)} тр/сек` : '—'; |
|
|
|
if (rate > 0 && consecutiveEmpty < 3) { |
|
const remainingEstimate = Math.max(0, 100 - (sessionDeleted % 100)); |
|
const etaSec = remainingEstimate / rate; |
|
const m = Math.floor(etaSec / 60); |
|
const s = Math.round(etaSec % 60); |
|
overlay.querySelector('#vkmc-eta').textContent = `~${m}м ${s}с до чекпойнта`; |
|
} else if (consecutiveEmpty >= 3) { |
|
overlay.querySelector('#vkmc-eta').textContent = 'финализация…'; |
|
} else { |
|
overlay.querySelector('#vkmc-eta').textContent = '—'; |
|
} |
|
} |
|
|
|
overlay.querySelector('#vkmc-pause').addEventListener('click', () => { |
|
paused = !paused; |
|
setPaused(paused); |
|
overlay.querySelector('#vkmc-pause').textContent = paused ? '▶ Продолжить' : '⏸ Пауза'; |
|
updateUI(); |
|
log(paused ? 'Пауза' : 'Продолжаю', '#f59e0b', true); |
|
}); |
|
overlay.querySelector('#vkmc-stop').addEventListener('click', () => { |
|
setRunning(false); |
|
log('Остановка пользователем', '#ef4444', true); |
|
}); |
|
|
|
// Горячие клавиши. |
|
document.addEventListener('keydown', (e) => { |
|
if (!e.ctrlKey || !e.shiftKey) return; |
|
if (e.key === 'P' || e.key === 'p') { |
|
e.preventDefault(); |
|
paused = !paused; |
|
setPaused(paused); |
|
overlay.querySelector('#vkmc-pause').textContent = paused ? '▶ Продолжить' : '⏸ Пауза'; |
|
updateUI(); |
|
log(paused ? 'Пауза (hotkey)' : 'Продолжаю (hotkey)', '#f59e0b', true); |
|
} else if (e.key === 'R' || e.key === 'r') { |
|
e.preventDefault(); |
|
paused = false; |
|
setPaused(false); |
|
overlay.querySelector('#vkmc-pause').textContent = '⏸ Пауза'; |
|
updateUI(); |
|
log('Resume (hotkey)', '#22c55e', true); |
|
} else if (e.key === 'S' || e.key === 's') { |
|
e.preventDefault(); |
|
setRunning(false); |
|
log('Stop (hotkey)', '#ef4444', true); |
|
} |
|
}); |
|
|
|
// Защита от закрытия вкладки. |
|
if (CONFIG.protectUnload) { |
|
window.addEventListener('beforeunload', (e) => { |
|
if (isRunning()) { |
|
e.preventDefault(); |
|
e.returnValue = 'VK Music Cleaner ещё работает. Точно закрыть?'; |
|
return e.returnValue; |
|
} |
|
}); |
|
} |
|
|
|
// ==================== DOM УТИЛИТЫ ==================== |
|
function fireHover(el) { |
|
const opts = { bubbles: true, cancelable: true, view: window, button: 0 }; |
|
el.dispatchEvent(new MouseEvent('pointerenter', opts)); |
|
el.dispatchEvent(new MouseEvent('pointerover', opts)); |
|
el.dispatchEvent(new MouseEvent('mouseenter', opts)); |
|
el.dispatchEvent(new MouseEvent('mouseover', opts)); |
|
el.dispatchEvent(new MouseEvent('mousemove', opts)); |
|
} |
|
|
|
// Найти ключ React Props на элементе. |
|
function getReactProps(el) { |
|
const key = Object.keys(el).find((k) => |
|
k.startsWith('__reactProps$') || k.startsWith('__reactEventHandlers$') |
|
); |
|
return key ? el[key] : null; |
|
} |
|
|
|
// «Умный» клик: сначала пробуем React onClick напрямую, потом фоллбэк на события. |
|
function smartClick(el) { |
|
const props = getReactProps(el); |
|
if (props && typeof props.onClick === 'function') { |
|
try { |
|
props.onClick({ |
|
preventDefault() {}, stopPropagation() {}, stopImmediatePropagation() {}, |
|
target: el, currentTarget: el, type: 'click', nativeEvent: {}, |
|
isDefaultPrevented: () => false, isPropagationStopped: () => false, |
|
}); |
|
return true; |
|
} catch (_) {} |
|
} |
|
// Фоллбэк. |
|
const opts = { bubbles: true, cancelable: true, view: window, button: 0 }; |
|
el.dispatchEvent(new MouseEvent('mouseover', opts)); |
|
el.dispatchEvent(new MouseEvent('mousemove', opts)); |
|
el.dispatchEvent(new MouseEvent('mousedown', opts)); |
|
el.dispatchEvent(new MouseEvent('mouseup', opts)); |
|
el.dispatchEvent(new MouseEvent('click', opts)); |
|
try { el.click(); } catch (_) {} |
|
return false; |
|
} |
|
|
|
// Поиск скролл-контейнера внутри секции треков. |
|
function findScrollContainer() { |
|
const roots = [ |
|
...document.querySelectorAll('[data-testid="AudioCatalog_SectionTracks"]'), |
|
...document.querySelectorAll('[data-testid="AudioCatalog_BlockMusicAudiosList"]'), |
|
...document.querySelectorAll('.vkuiList__host'), |
|
...document.querySelectorAll('[class*="AudioList"]'), |
|
]; |
|
for (const root of roots) { |
|
let node = root; |
|
for (let i = 0; i < 10 && node; i++) { |
|
const style = getComputedStyle(node); |
|
if ( |
|
(style.overflowY === 'auto' || style.overflowY === 'scroll') && |
|
node.scrollHeight > node.clientHeight + 50 |
|
) { |
|
return node; |
|
} |
|
node = node.parentElement; |
|
} |
|
} |
|
return document.scrollingElement || document.documentElement; |
|
} |
|
|
|
function scrollDownStep(container) { |
|
const before = container.scrollTop; |
|
container.scrollTop = before + CONFIG.scrollStep; |
|
window.scrollBy(0, CONFIG.scrollStep); |
|
return container.scrollTop !== before; |
|
} |
|
|
|
function scrollToEl(container, el) { |
|
try { el.scrollIntoView({ block: 'center', behavior: 'instant' }); } |
|
catch (_) { el.scrollIntoView(); } |
|
} |
|
|
|
function getTrackRows() { |
|
return [...document.querySelectorAll('[data-testid="MusicTrackRow"]')]; |
|
} |
|
|
|
function findRemoveButtonInRow(row) { |
|
let btn = row.querySelector( |
|
'[data-testid="MusicAudio_ToggleOwning"][data-testactive="true"],' + |
|
'[data-testid="MusicAudio_ToggleOwning"][aria-pressed="true"]' |
|
); |
|
if (btn) return btn; |
|
const all = row.querySelectorAll('[data-testid="MusicAudio_ToggleOwning"]'); |
|
for (const b of all) { |
|
const label = (b.textContent || b.getAttribute('aria-label') || '').toLowerCase(); |
|
if (label.includes('удалить')) return b; |
|
} |
|
for (const b of all) { |
|
const al = (b.getAttribute('aria-label') || '').toLowerCase(); |
|
if (al.includes('удалить')) return b; |
|
} |
|
return null; |
|
} |
|
|
|
async function collectVisibleButtons() { |
|
const rows = getTrackRows(); |
|
const buttons = []; |
|
for (const row of rows) { |
|
// Уникальный идентификатор строки для skip-list. |
|
const audioId = row.closest('[data-audio-id]')?.getAttribute('data-audio-id') || ''; |
|
if (stuckSet.has(audioId)) continue; |
|
fireHover(row); |
|
await sleep(CONFIG.hoverDelay); |
|
const btn = findRemoveButtonInRow(row); |
|
if (btn && btn.isConnected) buttons.push({ row, btn, audioId }); |
|
} |
|
return buttons; |
|
} |
|
|
|
async function waitBatchRemoved(items) { |
|
const start = Date.now(); |
|
const stuck = []; |
|
while (Date.now() - start < CONFIG.waitForRemoveMs) { |
|
let remaining = 0; |
|
for (const item of items) { |
|
const { row, btn, audioId } = item; |
|
if (!row.isConnected) continue; |
|
if (!btn.isConnected) continue; |
|
const a = btn.getAttribute('data-testactive'); |
|
const p = btn.getAttribute('aria-pressed'); |
|
if (a === 'false' || p === 'false') continue; |
|
remaining++; |
|
} |
|
if (remaining === 0) return { ok: true, stuck }; |
|
await sleep(150); |
|
} |
|
// Финальный проход: кто остался — те «застряли». |
|
for (const item of items) { |
|
const { row, btn, audioId } = item; |
|
if (audioId && row.isConnected && btn.isConnected) { |
|
const a = btn.getAttribute('data-testactive'); |
|
const p = btn.getAttribute('aria-pressed'); |
|
if (a !== 'false' && p !== 'false') { |
|
stuck.push(item); |
|
} |
|
} |
|
} |
|
return { ok: false, stuck }; |
|
} |
|
|
|
// ==================== ПЕРЕХВАТ ОШИБОК ВК ==================== |
|
const origFetch = window.fetch; |
|
window.fetch = async function (...args) { |
|
const res = await origFetch.apply(this, args); |
|
try { |
|
const clone = res.clone(); |
|
const text = await clone.text(); |
|
if (text.includes('bad_hash')) badHashDetected = true; |
|
} catch (_) {} |
|
return res; |
|
}; |
|
|
|
const origOpen = XMLHttpRequest.prototype.open; |
|
const origSend = XMLHttpRequest.prototype.send; |
|
XMLHttpRequest.prototype.open = function (method, url) { |
|
this._url = url; |
|
return origOpen.apply(this, arguments); |
|
}; |
|
XMLHttpRequest.prototype.send = function () { |
|
this.addEventListener('load', function () { |
|
try { |
|
if (this.responseText && this.responseText.includes('bad_hash')) { |
|
badHashDetected = true; |
|
} |
|
} catch (_) {} |
|
}); |
|
return origSend.apply(this, arguments); |
|
}; |
|
|
|
// ==================== ГЛАВНЫЙ ЦИКЛ ==================== |
|
log(`Старт. Уже удалено ранее: ${deleted}. Батч: ${currentBatchSize}/${currentBatchPause}мс`, '#4aa3ff', true); |
|
log(`Скорость: ${CONFIG.speed}. verbose: ${CONFIG.verbose}. Защита вкладки: ${CONFIG.protectUnload}`, '#94a3b8', true); |
|
|
|
const container = findScrollContainer(); |
|
log(`Скролл-контейнер: <${container.tagName.toLowerCase()} class="${(container.className || '').slice(0, 50)}">`, '#9c27b0', true); |
|
|
|
const uiInterval = setInterval(updateUI, 500); |
|
|
|
while (isRunning()) { |
|
set(K.lastRun, String(Date.now())); |
|
|
|
// Пауза. |
|
while (paused && isRunning()) { |
|
await sleep(300); |
|
} |
|
if (!isRunning()) break; |
|
|
|
// bad_hash → плавно замедляемся. |
|
if (badHashDetected) { |
|
consecutiveErrors++; |
|
consecutiveSuccess = 0; |
|
badHashDetected = false; |
|
currentBatchSize = Math.max(1, currentBatchSize - 1); |
|
currentBatchPause = Math.min(8000, currentBatchPause + 500); |
|
const pauseMs = 3000 + consecutiveErrors * 1500; |
|
log(`⚠️ bad_hash (#${consecutiveErrors}). Батч=${currentBatchSize}, пауза=${currentBatchPause}мс, ждём ${pauseMs}мс`, '#ef4444', true); |
|
updateUI(); |
|
await sleep(pauseMs); |
|
continue; |
|
} |
|
|
|
// Плавное восстановление скорости. |
|
if (consecutiveErrors > 0 || currentBatchSize < P.batchSize) { |
|
consecutiveSuccess++; |
|
if (consecutiveSuccess >= 5) { |
|
consecutiveSuccess = 0; |
|
if (consecutiveErrors > 0) consecutiveErrors--; |
|
if (currentBatchSize < P.batchSize) currentBatchSize++; |
|
if (currentBatchPause > P.batchPause) currentBatchPause = Math.max(P.batchPause, currentBatchPause - 200); |
|
log(`✓ Восстановление: батч=${currentBatchSize}, пауза=${currentBatchPause}мс`, '#22c55e'); |
|
} |
|
} |
|
|
|
const items = (await collectVisibleButtons()).filter((i) => !stuckSet.has(i.audioId)); |
|
|
|
if (items.length === 0) { |
|
const moved = scrollDownStep(container); |
|
await sleep(P.scrollPause); |
|
if (!moved) { |
|
consecutiveEmpty++; |
|
log(`Низ списка (попытка ${consecutiveEmpty})`, '#f59e0b'); |
|
if (consecutiveEmpty % 3 === 0) { |
|
container.scrollTop = 0; |
|
window.scrollTo(0, 0); |
|
await sleep(800); |
|
} |
|
if (consecutiveEmpty > 10) { |
|
log(`Долго пусто. Пауза ${CONFIG.emptyPauseMs}мс`, '#ef4444'); |
|
await sleep(CONFIG.emptyPauseMs); |
|
} |
|
if (consecutiveEmpty > 20) { |
|
log(`Похоже, всё удалено. Останавливаюсь.`, '#22c55e', true); |
|
break; |
|
} |
|
} |
|
updateUI(); |
|
continue; |
|
} |
|
|
|
consecutiveEmpty = 0; |
|
log(`Найдено ${items.length} кнопок. Батч=${currentBatchSize}`, '#4aa3ff'); |
|
|
|
for (let i = 0; i < items.length; i += currentBatchSize) { |
|
if (!isRunning()) break; |
|
while (paused && isRunning()) await sleep(300); |
|
if (badHashDetected) break; |
|
|
|
const batch = items.slice(i, i + currentBatchSize); |
|
|
|
if (batch[0] && batch[0].row.isConnected) { |
|
scrollToEl(container, batch[0].row); |
|
await sleep(60); |
|
} |
|
|
|
let clickedInBatch = 0; |
|
for (const { row, btn } of batch) { |
|
if (!row.isConnected || !btn.isConnected) continue; |
|
const a = btn.getAttribute('data-testactive'); |
|
const p = btn.getAttribute('aria-pressed'); |
|
if (a === 'false' || p === 'false') continue; |
|
fireHover(row); |
|
smartClick(btn); |
|
clickedInBatch++; |
|
} |
|
|
|
if (clickedInBatch > 0) { |
|
const { ok, stuck } = await waitBatchRemoved(batch); |
|
deleted += clickedInBatch - stuck.length; |
|
sessionDeleted += clickedInBatch - stuck.length; |
|
set(K.deleted, String(deleted)); |
|
pushStat(); |
|
|
|
// Добавляем «застрявших» в skip-list. |
|
for (const s of stuck) { |
|
if (s.audioId) { |
|
stuckSet.add(s.audioId); |
|
log(`⏭ Skip ${s.audioId} (застрял)`, '#f59e0b'); |
|
} |
|
} |
|
|
|
if (ok) { |
|
consecutiveSuccess++; |
|
log(`+${clickedInBatch} → всего ${deleted} (сессия ${sessionDeleted}) ✓`, '#22c55e'); |
|
} else { |
|
log(`+${clickedInBatch - stuck.length} → всего ${deleted} (${stuck.length} застряли)`, '#f59e0b'); |
|
} |
|
updateUI(); |
|
} |
|
|
|
await sleep(currentBatchPause); |
|
} |
|
|
|
scrollDownStep(container); |
|
await sleep(P.scrollPause); |
|
updateUI(); |
|
} |
|
|
|
// ==================== ФИНАЛИЗАЦИЯ ==================== |
|
clearInterval(uiInterval); |
|
|
|
window.fetch = origFetch; |
|
XMLHttpRequest.prototype.open = origOpen; |
|
XMLHttpRequest.prototype.send = origSend; |
|
|
|
setRunning(false); |
|
setPaused(false); |
|
|
|
if (overlay.parentNode) overlay.remove(); |
|
|
|
const elapsedSec = Math.round((Date.now() - startTime) / 1000); |
|
const m = Math.floor(elapsedSec / 60); |
|
const s = elapsedSec % 60; |
|
log(`=== Готово! Удалено: ${deleted} (за сессию: ${sessionDeleted} за ${m}м ${s}с) ===`, '#22c55e', true); |
|
log(`Skip-list: ${stuckSet.size} треков не поддались.`, '#94a3b8', true); |
|
})(); |