#!/usr/bin/env node /* eslint-disable no-console */ const { chromium } = require('playwright'); function parseArgs(argv) { const opts = { headless: false, delayMs: 1100, navTimeoutMs: 30000, loginTimeoutMs: 300000, dryRun: false, startPage: 1, maxPages: null, mode: 'all' }; for (const arg of argv) { if (arg === '--headless') opts.headless = true; else if (arg === '--dry-run') opts.dryRun = true; else if (arg.startsWith('--delay-ms=')) opts.delayMs = Math.max(1000, Number(arg.split('=')[1]) || 1100); else if (arg.startsWith('--nav-timeout-ms=')) opts.navTimeoutMs = Math.max(5000, Number(arg.split('=')[1]) || 30000); else if (arg.startsWith('--login-timeout-ms=')) opts.loginTimeoutMs = Math.max(10000, Number(arg.split('=')[1]) || 300000); else if (arg.startsWith('--start-page=')) opts.startPage = Math.max(1, Number(arg.split('=')[1]) || 1); else if (arg.startsWith('--max-pages=')) opts.maxPages = Math.max(1, Number(arg.split('=')[1]) || 1); else if (arg.startsWith('--mode=')) { const mode = arg.split('=')[1]; if (mode === 'all' || mode === 'added') opts.mode = mode; } } return opts; } function pageUrl(page, mode) { const base = new URL('https://library.vcvrack.com/'); base.searchParams.set('page', String(page)); base.searchParams.set('limit', '50'); base.searchParams.set('query', ''); base.searchParams.set('tag', ''); base.searchParams.set('sort', 'creationTimestamp'); base.searchParams.set('brand', ''); base.searchParams.set('license', ''); base.searchParams.set('modules', mode === 'added' ? 'added' : ''); base.searchParams.set('plugins', ''); return base.toString(); } async function scanAndRemoveOnPage(page, dryRun) { return page.evaluate(({ dryRunArg }) => { function isVisible(el) { const style = window.getComputedStyle(el); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; const rect = el.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } function findModuleCard(buttonEl) { let node = buttonEl; while (node && node !== document.body) { if (node.querySelector && node.querySelector('h4') && node.querySelector('button')) { return node; } node = node.parentElement; } return buttonEl.parentElement || document.body; } function getMaxPage() { const pageNums = Array.from(document.querySelectorAll('a')) .map((a) => { try { const url = new URL(a.href, location.origin); const value = Number(url.searchParams.get('page')); return Number.isFinite(value) ? value : null; } catch { return null; } }) .filter((n) => n !== null); if (!pageNums.length) return 1; return Math.max(...pageNums); } const visibleRemoveButtons = Array.from(document.querySelectorAll('button.library-remove')).filter(isVisible); let removed = 0; let keptHardwareClone = 0; for (const button of visibleRemoveButtons) { const card = findModuleCard(button); const tags = Array.from(card.querySelectorAll('a')).map((a) => (a.textContent || '').trim().toLowerCase()); const hasHardwareClone = tags.includes('hardware clone'); if (hasHardwareClone) { keptHardwareClone += 1; continue; } if (!dryRunArg) { button.click(); } removed += 1; } return { url: location.href, maxPage: getMaxPage(), visibleRemoveButtons: visibleRemoveButtons.length, removed, keptHardwareClone }; }, { dryRunArg: dryRun }); } function reportPage(pageNumber, info, dryRun) { const modeText = dryRun ? 'dry-run' : 'apply'; const unchanged = info.removed === 0 ? 'yes' : 'no'; console.log( `[page ${pageNumber}] mode=${modeText} candidates=${info.visibleRemoveButtons} removed=${info.removed} keptHardware=${info.keptHardwareClone} unchanged=${unchanged} url=${info.url}` ); } async function navigateWithRetry(page, url, timeoutMs, label) { const attempts = 3; for (let attempt = 1; attempt <= attempts; attempt += 1) { try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: timeoutMs }); return; } catch (err) { if (attempt === attempts) { throw new Error(`${label} failed after ${attempts} attempts: ${err.message}`); } console.warn(`${label} attempt ${attempt} failed, retrying... ${err.message}`); await page.waitForTimeout(1000); } } } async function isLoggedInOnVcv(page) { return page.evaluate(() => { const links = Array.from(document.querySelectorAll('a')).map((a) => (a.textContent || '').trim().toLowerCase()); return links.includes('log out') || links.includes('account'); }); } async function isLoggedInOnLibrary(page) { return page.evaluate(() => { const links = Array.from(document.querySelectorAll('a')).map((a) => (a.textContent || '').trim().toLowerCase()); return !links.includes('register / log in'); }); } async function waitForLogin(page, loginTimeoutMs) { const start = Date.now(); let lastStatusLog = 0; while (Date.now() - start < loginTimeoutMs) { try { const loggedIn = await isLoggedInOnVcv(page); if (loggedIn) return; } catch (err) { // Ignore transient context errors while login redirects are in flight. const message = err && err.message ? err.message : String(err); if (!/Execution context was destroyed|Target closed|Frame was detached/i.test(message)) { throw err; } } const elapsedSec = Math.floor((Date.now() - start) / 1000); if (elapsedSec - lastStatusLog >= 5) { console.log(`Waiting for login... ${elapsedSec}s elapsed`); lastStatusLog = elapsedSec; } await page.waitForTimeout(1000); } throw new Error('Timed out waiting for login. Complete login in the browser and rerun, or increase --login-timeout-ms.'); } async function main() { const opts = parseArgs(process.argv.slice(2)); console.log('Starting VCV cleanup with options:', opts); console.log('Rate limit: one page request per second or slower.'); const browser = await chromium.launch({ headless: opts.headless }); const context = await browser.newContext(); const page = await context.newPage(); try { await navigateWithRetry(page, 'https://vcvrack.com/login', opts.navTimeoutMs, 'Login page navigation'); console.log('Log in in the opened browser window. The script will auto-continue once login is detected.'); await waitForLogin(page, opts.loginTimeoutMs); const firstUrl = pageUrl(opts.startPage, opts.mode); console.log(`Navigating page ${opts.startPage}: ${firstUrl}`); await navigateWithRetry(page, firstUrl, opts.navTimeoutMs, `Page ${opts.startPage} navigation`); await page.waitForTimeout(opts.delayMs); const loggedInLibrary = await isLoggedInOnLibrary(page); if (!loggedInLibrary) { throw new Error('Not logged in on VCV Library after login detection. Aborting to avoid false no-op run.'); } const first = await scanAndRemoveOnPage(page, opts.dryRun); const lastPage = opts.maxPages ? Math.min(opts.startPage + opts.maxPages - 1, first.maxPage) : first.maxPage; const summary = { mode: opts.mode, startPage: opts.startPage, lastPage, pagesScanned: 0, totalVisibleRemoveButtons: 0, totalRemovedNonHardwareClone: 0, totalKeptHardwareClone: 0 }; function addStats(info) { summary.pagesScanned += 1; summary.totalVisibleRemoveButtons += info.visibleRemoveButtons; summary.totalRemovedNonHardwareClone += info.removed; summary.totalKeptHardwareClone += info.keptHardwareClone; } addStats(first); reportPage(opts.startPage, first, opts.dryRun); for (let p = opts.startPage + 1; p <= lastPage; p += 1) { await page.waitForTimeout(opts.delayMs); const url = pageUrl(p, opts.mode); console.log(`Navigating page ${p}/${lastPage}: ${url}`); await navigateWithRetry(page, url, opts.navTimeoutMs, `Page ${p} navigation`); const info = await scanAndRemoveOnPage(page, opts.dryRun); addStats(info); reportPage(p, info, opts.dryRun); } console.log('Done. Summary:'); console.log(JSON.stringify(summary, null, 2)); } finally { await context.close(); await browser.close(); } } main().catch((err) => { console.error(err); process.exit(1); });