From 9c67af04c1948ed1f27970147bf2e74883d43112 Mon Sep 17 00:00:00 2001 From: Olaf Date: Mon, 24 Aug 2026 10:33:01 +0200 Subject: [PATCH] Working version --- .gitignore | 2 + README.md | 68 ++++++++ package-lock.json | 59 +++++++ package.json | 12 ++ scripts/clean-non-hardware-clone.js | 254 ++++++++++++++++++++++++++++ 5 files changed, 395 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/clean-non-hardware-clone.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a56a7ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules + diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed220f7 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# VCV Library Non-Hardware-Clone Cleanup + + +This vibe-coded script scans VCV Library pages and removes modules that are currently removable and not tagged `Hardware clone`. + +## Safety and rate limit + +- Default rate limit is one page request every 1100 ms. +- Use `--dry-run` first to preview actions. + +## Setup + +```bash +npm install +``` + +## Run + +```bash +npm run clean:vcv -- --dry-run +``` + +Then run the real cleanup: + +```bash +npm run clean:vcv +``` + +The browser opens to the login page. Log in manually and the script auto-continues when login is detected. + +## Per-page reporting + +The script prints one report line per page, for example: + +```text +[page 12] mode=apply candidates=5 removed=4 keptHardware=1 unchanged=no url=https://library.vcvrack.com/?page=12&... +``` + +- `candidates`: visible `Remove` buttons on that page +- `removed`: non-hardware-clone modules removed (or would be removed in dry-run) +- `keptHardware`: removable modules kept because they have `Hardware clone` +- `unchanged`: `yes` when no removals happened on that page + +## Options + +- `--dry-run` preview only, no clicks +- `--headless` run without opening browser UI +- `--delay-ms=1100` page query delay (minimum is 1000) +- `--nav-timeout-ms=30000` timeout per page navigation +- `--login-timeout-ms=300000` max wait for login detection +- `--mode=all` scan all library pages (default) +- `--mode=added` scan only your added modules view +- `--start-page=1` start from a page index +- `--max-pages=10` limit pages scanned from start page + +## Examples + +Scan all pages with rate limit: + +```bash +npm run clean:vcv -- --mode=all --delay-ms=1100 +``` + +Scan only added modules: + +```bash +npm run clean:vcv -- --mode=added --delay-ms=1100 +``` diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5bff437 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,59 @@ +{ + "name": "vcv-rack-scraper", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vcv-rack-scraper", + "version": "1.0.0", + "dependencies": { + "playwright": "^1.55.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9f94dfa --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "vcv-rack-scraper", + "version": "1.0.0", + "private": true, + "description": "Automate VCV Library cleanup for non-hardware-clone modules", + "scripts": { + "clean:vcv": "node scripts/clean-non-hardware-clone.js" + }, + "dependencies": { + "playwright": "^1.55.0" + } +} diff --git a/scripts/clean-non-hardware-clone.js b/scripts/clean-non-hardware-clone.js new file mode 100644 index 0000000..4650e7c --- /dev/null +++ b/scripts/clean-non-hardware-clone.js @@ -0,0 +1,254 @@ +#!/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); +});