Files
VCV-Hardware-Module-Selector/scripts/clean-non-hardware-clone.js
T
Olaf a006b9ca13 Fix missed removals from async ownership state rendering
Add/Remove button state loads asynchronously after page render, so a
fixed delay could scan before all buttons updated and skip real
candidates (confirmed via Strum's VCV Modules brand page). Add a
button-state stabilization wait and multi-pass per-page scanning to
catch late-rendering items.
2026-08-24 11:43:08 +02:00

302 lines
10 KiB
JavaScript

#!/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 waitForStableActionButtons(page, { stableForMs = 800, maxWaitMs = 8000 } = {}) {
function signature() {
const buttons = Array.from(document.querySelectorAll('button.library-add, button.library-remove'));
return buttons
.map((b) => `${b.className.includes('library-add') ? 'add' : 'remove'}:${b.className.includes('hidden') ? 'h' : 'v'}`)
.join('|');
}
const start = Date.now();
let lastSig = await page.evaluate(signature);
let stableSince = Date.now();
while (Date.now() - start < maxWaitMs) {
await page.waitForTimeout(200);
const sig = await page.evaluate(signature);
if (sig === lastSig) {
if (Date.now() - stableSince >= stableForMs) return;
} else {
lastSig = sig;
stableSince = Date.now();
}
}
}
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 });
}
async function scanAndRemoveWithRetries(page, dryRun) {
let combined = await scanAndRemoveOnPage(page, dryRun);
// Extra passes catch modules whose ownership state renders after the initial stabilization wait.
for (let i = 0; i < 2; i += 1) {
await waitForStableActionButtons(page, { stableForMs: 600, maxWaitMs: 4000 });
const again = await scanAndRemoveOnPage(page, dryRun);
if (again.visibleRemoveButtons === 0 && again.removed === 0) break;
combined = {
url: again.url,
maxPage: again.maxPage,
visibleRemoveButtons: combined.visibleRemoveButtons + again.visibleRemoveButtons,
removed: combined.removed + again.removed,
keptHardwareClone: combined.keptHardwareClone + again.keptHardwareClone
};
}
return combined;
}
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.');
}
await waitForStableActionButtons(page);
const first = await scanAndRemoveWithRetries(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`);
await waitForStableActionButtons(page);
const info = await scanAndRemoveWithRetries(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);
});