Working version

This commit is contained in:
Olaf
2026-08-24 10:33:01 +02:00
commit 9c67af04c1
5 changed files with 395 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules
+68
View File
@@ -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
```
+59
View File
@@ -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"
}
}
}
}
+12
View File
@@ -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"
}
}
+254
View File
@@ -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);
});