feat: fetch-novel builds an EPUB from any plugin; Kavita TOC fix
Publish Plugins / Publish Plugins (push) Has been cancelled
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>
This commit is contained in:
+57
-104
@@ -1,20 +1,8 @@
|
||||
// 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';
|
||||
import { loadPlugins, print } from './load-plugins.js';
|
||||
|
||||
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;
|
||||
@@ -24,103 +12,68 @@ 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 plugins = await loadPlugins(
|
||||
langArgs.length ? langArgs : ['english', 'multi'],
|
||||
);
|
||||
process.stderr.write(`Searching ${plugins.length} sources for "${term}"...\n`);
|
||||
|
||||
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++;
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
process.stderr.write(
|
||||
`${matches.length} matches (* = exact title), ${hits.length - matches.length} loose results hidden; ` +
|
||||
`${bundles.length - failed}/${bundles.length} sources answered.\n`,
|
||||
};
|
||||
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}`,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(outdir, { recursive: true, force: true });
|
||||
}
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user