Files
masi 5c444fc35a feat(scripts): cached plugin bundle, single-plugin fetch, free-chapter prefix, nightly appender
- load-plugins: bundle once per source revision with an id index; loadPlugin(id)
  loads one plugin (144 ms / 87 MB instead of ~15 s / 1.2 GB).
- fetch-novel: keep only the contiguous run of free chapters (no locked teasers).
- update-novels: append newly free chapters to our EPUBs without moving positions.
- find-novel --json for programs; shared EPUB pieces in epub.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 14:05:51 -03:00

87 lines
2.7 KiB
JavaScript
Raw Permalink 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 [--json] "<title>" [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 "<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));
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);