Files
lnreader-plugins/scripts/find-novel.js
T
masi fbb91a9c84
Publish Plugins / Publish Plugins (push) Has been cancelled
feat(multi): add Kavita plugin; keep request logs out of find output
Kavita plugin reads EPUB series from a self-hosted Kavita server via its
plugin API key, one LNReader chapter per EPUB page named from the TOC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:58:19 -03:00

127 lines
4.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 * 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 "<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) {
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);