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:
@@ -0,0 +1,113 @@
|
||||
// Append newly published (and newly free) chapters to EPUBs built by fetch-novel.js.
|
||||
// Usage: node scripts/update-novels.js <library-dir>
|
||||
// Only EPUBs whose identifier is urn:lnreader:<plugin>:<path> are touched. Chapters are
|
||||
// appended at the end, so existing positions (Kavita pages, reading progress,
|
||||
// illustration anchors) never move. Prints one line per updated novel.
|
||||
import JSZip from 'jszip';
|
||||
import { load } from 'cheerio';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { loadPlugin, print } from './load-plugins.js';
|
||||
import { chapterPage, esc, toXhtml } from './epub.js';
|
||||
|
||||
const CONCURRENCY = 3;
|
||||
const RETRIES = 4;
|
||||
|
||||
const [library] = process.argv.slice(2);
|
||||
if (!library) {
|
||||
console.error('Usage: node scripts/update-novels.js <library-dir>');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const retry = async (label, fn) => {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
if (attempt >= RETRIES) throw new Error(`${label}: ${e.message}`);
|
||||
await new Promise(r => setTimeout(r, 1000 * 2 ** attempt));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function update(file) {
|
||||
const zip = await JSZip.loadAsync(fs.readFileSync(file));
|
||||
const opfPath = 'OEBPS/content.opf';
|
||||
if (!zip.file(opfPath)) return;
|
||||
const opf = load(await zip.file(opfPath).async('string'), { xml: true });
|
||||
const uid = opf('dc\\:identifier').first().text();
|
||||
const m = uid.match(/^urn:lnreader:([^:]+):(.+)$/);
|
||||
if (!m) return;
|
||||
const [, pluginId, novelPath] = m;
|
||||
const have = opf('spine itemref').length;
|
||||
|
||||
const plugin = await loadPlugin(pluginId);
|
||||
const novel = await retry('novel', () => plugin.parseNovel(novelPath));
|
||||
const all = novel.chapters || [];
|
||||
const firstLocked = all.findIndex(c => c.name.includes('🔒'));
|
||||
const free = firstLocked < 0 ? all : all.slice(0, firstLocked);
|
||||
if (free.length <= have) return;
|
||||
|
||||
const added = free.slice(have);
|
||||
const bodies = new Array(added.length);
|
||||
const queue = added.map((_, i) => i);
|
||||
await Promise.all(
|
||||
Array.from({ length: CONCURRENCY }, async () => {
|
||||
for (let i; (i = queue.shift()) !== undefined;) {
|
||||
bodies[i] = toXhtml(
|
||||
await retry(added[i].name, () => plugin.parseChapter(added[i].path)),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const nav = load(await zip.file('OEBPS/nav.xhtml').async('string'), {
|
||||
xml: true,
|
||||
});
|
||||
added.forEach((c, i) => {
|
||||
const id = `ch${String(have + i + 1).padStart(4, '0')}`;
|
||||
zip.file(`OEBPS/${id}.xhtml`, chapterPage(c.name, bodies[i]));
|
||||
opf('manifest').append(
|
||||
`<item id="${id}" href="${id}.xhtml" media-type="application/xhtml+xml"/>`,
|
||||
);
|
||||
opf('spine').append(`<itemref idref="${id}"/>`);
|
||||
nav('nav ol')
|
||||
.first()
|
||||
.append(`<li><a href="${id}.xhtml">${esc(c.name)}</a></li>`);
|
||||
});
|
||||
opf('meta[property="dcterms:modified"]').text(
|
||||
new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
|
||||
);
|
||||
zip.file(opfPath, opf.xml());
|
||||
zip.file('OEBPS/nav.xhtml', nav.xml());
|
||||
// EPUB requires mimetype stored uncompressed; loaded entries lose that option.
|
||||
zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' });
|
||||
const tmp = `${file}.tmp`;
|
||||
fs.writeFileSync(
|
||||
tmp,
|
||||
await zip.generateAsync({
|
||||
type: 'nodebuffer',
|
||||
compression: 'DEFLATE',
|
||||
mimeType: 'application/epub+zip',
|
||||
}),
|
||||
);
|
||||
fs.renameSync(tmp, file);
|
||||
print(
|
||||
`${path.basename(file)}: +${added.length} chapters (${have} -> ${free.length})`,
|
||||
);
|
||||
}
|
||||
|
||||
const epubs = fs
|
||||
.readdirSync(library, { recursive: true })
|
||||
.filter(f => f.endsWith('.epub'))
|
||||
.map(f => path.join(library, f));
|
||||
let failed = 0;
|
||||
for (const file of epubs) {
|
||||
try {
|
||||
await update(file);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
process.stderr.write(`${path.basename(file)}: ${e.message}\n`);
|
||||
}
|
||||
}
|
||||
process.exit(failed ? 1 : 0);
|
||||
Reference in New Issue
Block a user