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>
This commit is contained in:
masi
2026-09-26 14:05:51 -03:00
parent 750c2824ce
commit 5c444fc35a
5 changed files with 258 additions and 77 deletions
+94 -36
View File
@@ -1,6 +1,10 @@
// Load compiled plugin instances outside the app, for the novel CLI scripts.
// Bundling ~165 plugins takes ~15 s and each bundle carries its own libraries, so the
// bundle is built once per source revision and cached, with an id -> file index:
// fetching a novel then loads a single plugin instead of all of them.
import * as esbuild from 'esbuild';
import { execFileSync } from 'child_process';
import { createHash } from 'crypto';
import { createRequire } from 'module';
import { fileURLToPath } from 'url';
import path, { dirname } from 'path';
@@ -9,60 +13,114 @@ import os from 'os';
const require = createRequire(import.meta.url);
export const ROOT = path.resolve(dirname(fileURLToPath(import.meta.url)), '..');
const CACHE =
process.env.NOVEL_PLUGIN_CACHE ||
path.join(os.homedir(), '.cache', 'novel-plugins');
export const LANGS = ['english', 'multi'];
// The shared fetch helper logs every request with its headers (credentials
// included). Callers print their output explicitly with `print`.
export const print = (...args) => process.stdout.write(args.join(' ') + '\n');
console.log = () => undefined;
/** Bundle and instantiate every plugin in the given language folders. */
export async function loadPlugins(langs) {
const git = args =>
execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' }).trim();
/** Cache key: the commit plus any uncommitted changes to plugins or libs. */
function revision(langs) {
let head = 'nogit';
let dirty = '';
try {
head = git(['rev-parse', 'HEAD']);
dirty = git(['diff', 'HEAD', '--', 'plugins', 'src']);
} catch {
// Not a git checkout: the cache is keyed by language set only.
}
return createHash('sha256')
.update(`${head}\n${dirty}\n${langs.join(',')}`)
.digest('hex')
.slice(0, 16);
}
/** Build (once per revision) and return the cache dir holding bundles + index.json. */
async function bundle(langs) {
const dir = path.join(CACHE, revision(langs));
if (fs.existsSync(path.join(dir, 'index.json'))) return dir;
// 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))
const src = path.join(ROOT, 'plugins', lang);
if (!fs.existsSync(src))
throw new Error(`No plugin folder: plugins/${lang}`);
return fs
.readdirSync(dir)
.readdirSync(src)
.filter(f => f.endsWith('.ts'))
.map(f => path.join(dir, f));
.map(f => path.join(src, f));
});
const outdir = fs.mkdtempSync(path.join(os.tmpdir(), 'novel-plugins-'));
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 tmp = `${dir}.tmp-${process.pid}`;
fs.rmSync(tmp, { recursive: true, force: true });
const result = await esbuild.build({
entryPoints,
outdir: tmp,
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 plugins = [];
for (const file of fs.readdirSync(outdir, { recursive: true })) {
if (!file.endsWith('.cjs')) continue;
try {
const mod = require(path.join(outdir, file));
plugins.push(mod.default ?? mod);
} catch {
// A plugin that throws on load is skipped, like a broken source in the app.
}
const index = {};
for (const file of fs.readdirSync(tmp, { recursive: true })) {
if (!file.endsWith('.cjs')) continue;
try {
const mod = require(path.join(tmp, file));
const plugin = mod.default ?? mod;
if (plugin?.id) index[plugin.id] = file;
} catch {
// A plugin that throws on load is skipped, like a broken source in the app.
}
return plugins;
} finally {
fs.rmSync(outdir, { recursive: true, force: true });
}
fs.writeFileSync(path.join(tmp, 'index.json'), JSON.stringify(index));
fs.rmSync(dir, { recursive: true, force: true });
fs.renameSync(tmp, dir);
return dir;
}
function instantiate(dir, file) {
const mod = require(path.join(dir, file));
return mod.default ?? mod;
}
/** Every plugin in the given language folders (for searching across sources). */
export async function loadPlugins(langs = LANGS) {
const dir = await bundle(langs);
const index = JSON.parse(fs.readFileSync(path.join(dir, 'index.json')));
const plugins = [];
for (const file of Object.values(index)) {
try {
plugins.push(instantiate(dir, file));
} catch {
// Skipped, as at bundle time.
}
}
return plugins;
}
/** One plugin by id, loading nothing else. */
export async function loadPlugin(id, langs = LANGS) {
const dir = await bundle(langs);
const file = JSON.parse(fs.readFileSync(path.join(dir, 'index.json')))[id];
if (!file) throw new Error(`No plugin with id "${id}"`);
return instantiate(dir, file);
}