// Search every plugin of the given languages for a title and list the sources
// that carry it. Usage: node scripts/find-novel.js [--json] "
" [lang...]
// Languages are plugin folder names (default: english multi). --json prints one JSON
// array of {source, id, name, path, url, exact} for programs (the request page).
import { loadPlugins, print } from './load-plugins.js';
const TIMEOUT_MS = 20_000;
const CONCURRENCY = 16;
const PER_SOURCE = 3;
const args = process.argv.slice(2);
const asJson = args[0] === '--json';
const [term, ...langArgs] = asJson ? args.slice(1) : args;
if (!term) {
console.error('Usage: node scripts/find-novel.js "" [lang...]');
process.exit(2);
}
const plugins = await loadPlugins(
langArgs.length ? langArgs : ['english', 'multi'],
);
process.stderr.write(`Searching ${plugins.length} sources for "${term}"...\n`);
const hits = [];
let failed = 0;
const queue = [...plugins];
const worker = async () => {
for (let plugin; (plugin = queue.shift());) {
try {
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,
id: plugin.id,
name: novel.name,
path: novel.path,
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));
if (asJson) {
print(JSON.stringify(matches.map(h => ({ ...h, exact: rank(h) === 0 }))));
process.exit(0);
}
for (const h of matches) {
print(
`${rank(h) === 0 ? '*' : ' '} ${h.source.padEnd(28)} ${h.name}\n` +
` ${''.padEnd(28)} ${h.url}\n` +
` ${''.padEnd(28)} novel fetch ${h.id} ${h.path}`,
);
}
process.stderr.write(
`${matches.length} matches (* = exact title), ${hits.length - matches.length} loose results hidden; ` +
`${plugins.length - failed}/${plugins.length} sources answered.\n`,
);
// Keep-alive sockets can hold the event loop open; results are already printed.
process.exit(0);