fba9a35075
Publish Plugins / Publish Plugins (push) Has been cancelled
- scripts/load-plugins.js: shared plugin loader for the novel CLI scripts - scripts/fetch-novel.js: download a novel via its plugin into a clean EPUB 3 - find-novel prints the plugin id/path for each match - kavita 1.0.1: innermost TOC entry names the chapter Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
80 lines
2.4 KiB
JavaScript
80 lines
2.4 KiB
JavaScript
// Search every plugin of the given languages for a title and list the sources
|
||
// that carry it. Usage: node scripts/find-novel.js "<title>" [lang...]
|
||
// Languages are plugin folder names (default: english multi).
|
||
import { loadPlugins, print } from './load-plugins.js';
|
||
|
||
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 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));
|
||
|
||
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);
|