Files
lnreader-plugins/scripts/update-novels.js
T
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

114 lines
3.7 KiB
JavaScript

// 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);