// Search every plugin of the given languages for a title and list the sources // that carry it. Usage: node scripts/find-novel.js "" [lang...] // Languages are plugin folder names (default: english multi). import * as esbuild from 'esbuild'; import { execFileSync } from 'child_process'; import { createRequire } from 'module'; import { fileURLToPath } from 'url'; import path, { dirname } from 'path'; import fs from 'fs'; import os from 'os'; const require = createRequire(import.meta.url); const ROOT = path.resolve(dirname(fileURLToPath(import.meta.url)), '..'); const TIMEOUT_MS = 20_000; const CONCURRENCY = 16; const PER_SOURCE = 3; const [term, ...langArgs] = process.argv.slice(2); if (!term) { console.error('Usage: node scripts/find-novel.js "<title>" [lang...]'); process.exit(2); } const langs = langArgs.length ? langArgs : ['english', 'multi']; // Multisrc plugins (Madara, NovelFire, ...) are generated, not committed. execFileSync('node', ['plugins/multisrc/generate-multisrc-plugins.js'], { cwd: ROOT, stdio: 'ignore', }); const entryPoints = langs.flatMap(lang => { const dir = path.join(ROOT, 'plugins', lang); if (!fs.existsSync(dir)) throw new Error(`No plugin folder: plugins/${lang}`); return fs .readdirSync(dir) .filter(f => f.endsWith('.ts')) .map(f => path.join(dir, f)); }); const outdir = fs.mkdtempSync(path.join(os.tmpdir(), 'find-novel-')); try { const result = await esbuild.build({ entryPoints, outdir, bundle: true, platform: 'node', format: 'cjs', target: 'node22', outExtension: { '.js': '.cjs' }, logLevel: 'silent', alias: { '@libs': path.join(ROOT, 'src/libs'), '@': path.join(ROOT, 'src'), }, }); if (result.errors.length) throw new Error('Plugin bundling failed'); const bundles = fs .readdirSync(outdir, { recursive: true }) .filter(f => f.endsWith('.cjs')) .map(f => path.join(outdir, f)); process.stderr.write( `Searching ${bundles.length} sources for "${term}"...\n`, ); const hits = []; let failed = 0; const queue = [...bundles]; const worker = async () => { for (let file; (file = queue.shift());) { let plugin; try { const mod = require(file); plugin = mod.default ?? mod; const found = await Promise.race([ plugin.searchNovels(term, 1), new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), TIMEOUT_MS), ), ]); for (const novel of (found || []).slice(0, PER_SOURCE)) { const url = plugin.resolveUrl ? plugin.resolveUrl(novel.path, true) : new URL(novel.path, plugin.site).href; hits.push({ source: plugin.name, name: novel.name, url }); } } catch { failed++; } } }; await Promise.all(Array.from({ length: CONCURRENCY }, worker)); // Keep titles containing every word of the query (quotes/punctuation ignored). const norm = t => t .toLowerCase() .replace(/[’']/g, '') .replace(/[^a-z0-9]+/g, ' ') .trim(); const words = norm(term).split(' '); const exact = norm(term); const matches = hits.filter(h => words.every(w => norm(h.name).split(' ').includes(w)), ); const rank = h => (norm(h.name) === exact ? 0 : 1); matches.sort((a, b) => rank(a) - rank(b) || a.source.localeCompare(b.source)); for (const h of matches) { console.log( `${rank(h) === 0 ? '*' : ' '} ${h.source.padEnd(28)} ${h.name}\n ${''.padEnd(28)} ${h.url}`, ); } process.stderr.write( `${matches.length} matches (* = exact title), ${hits.length - matches.length} loose results hidden; ` + `${bundles.length - failed}/${bundles.length} sources answered.\n`, ); } finally { fs.rmSync(outdir, { recursive: true, force: true }); } // Keep-alive sockets can hold the event loop open; results are already printed. process.exit(0);