// 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);
// The shared fetch helper logs every request with its headers (credentials
// included); keep that out of the output. Results go to stdout explicitly.
const print = (...args) => process.stdout.write(args.join(' ') + '\n');
console.log = () => {};
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 "" [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) {
print(
`${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);