Working version
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
|
||||
@@ -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
|
||||
```
|
||||
Generated
+59
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user