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