5c444fc35a
- 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>
114 lines
3.7 KiB
JavaScript
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);
|