255 lines
8.4 KiB
JavaScript
255 lines
8.4 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 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);
|
|
});
|