Files
masi fba9a35075
Publish Plugins / Publish Plugins (push) Has been cancelled
feat: fetch-novel builds an EPUB from any plugin; Kavita TOC fix
- scripts/load-plugins.js: shared plugin loader for the novel CLI scripts
- scripts/fetch-novel.js: download a novel via its plugin into a clean EPUB 3
- find-novel prints the plugin id/path for each match
- kavita 1.0.1: innermost TOC entry names the chapter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:40:23 -03:00

229 lines
7.4 KiB
TypeScript

import { fetchApi, type FetchInit } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { NovelStatus } from '@libs/novelStatus';
import { storage } from '@libs/storage';
// Reads EPUB series from a self-hosted Kavita server. Each LNReader chapter is
// one EPUB page (spine item), named from the book's table of contents.
type Library = { id: number; type: number };
type Series = { id: number; name: string; libraryId: number };
type SearchResult = {
series?: { seriesId: number; name: string; libraryId: number }[];
};
type Volume = {
name: string;
chapters: { id: number; title: string; pages: number }[];
};
type TocItem = { title: string; page: number; children?: TocItem[] };
type Metadata = {
summary?: string;
genres?: { title: string }[];
writers?: { name: string }[];
publicationStatus?: number;
};
const BOOK_LIBRARY_TYPES = new Set([2, 4]); // Book, LightNovel
const STATUS: Record<number, string> = {
0: NovelStatus.Ongoing,
1: NovelStatus.OnHiatus,
2: NovelStatus.Completed,
3: NovelStatus.Cancelled,
4: NovelStatus.Completed,
};
const PAGE_SIZE = 20;
class KavitaPlugin implements Plugin.PluginBase {
id = 'kavita';
name = 'Kavita';
icon = 'src/multi/kavita/icon.png';
version = '1.0.1';
site = 'https://www.kavitareader.com';
pluginSettings = {
url: { value: '', label: 'Server URL (e.g. https://novel.example.com)' },
apiKey: { value: '', label: 'API key (Kavita: Settings → Account)' },
};
token?: string;
get base(): string {
const url = String(storage.get('url') || '')
.trim()
.replace(/\/+$/, '');
if (!url)
throw new Error('Set the Kavita server URL in the plugin settings');
return url;
}
get apiKey(): string {
const key = String(storage.get('apiKey') || '').trim();
if (!key) throw new Error('Set your Kavita API key in the plugin settings');
return key;
}
async authenticate(): Promise<string> {
const res = await fetchApi(
`${this.base}/api/Plugin/authenticate?apiKey=${encodeURIComponent(this.apiKey)}&pluginName=LNReader`,
{ method: 'POST' },
);
if (!res.ok)
throw new Error(`Kavita login failed (${res.status}); check the API key`);
this.token = (await res.json()).token;
return this.token!;
}
async request(
path: string,
init: FetchInit = {},
retry = true,
): Promise<Response> {
const token = this.token || (await this.authenticate());
const res = await fetchApi(`${this.base}/api/${path}`, {
...init,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
...(init.headers as Record<string, string>),
},
});
if (res.status === 401 && retry) {
this.token = undefined;
return this.request(path, init, false);
}
if (!res.ok) throw new Error(`Kavita ${res.status} for ${path}`);
return res;
}
json = async <T>(path: string, init?: FetchInit): Promise<T> =>
(await this.request(path, init)).json();
async bookLibraryIds(): Promise<number[]> {
const libs = await this.json<Library[]>('Library/libraries');
return libs.filter(l => BOOK_LIBRARY_TYPES.has(l.type)).map(l => l.id);
}
cover = (seriesId: number) =>
`${this.base}/api/image/series-cover?seriesId=${seriesId}&apiKey=${encodeURIComponent(this.apiKey)}`;
toItem = (s: { id: number; name: string }): Plugin.NovelItem => ({
name: s.name,
path: String(s.id),
cover: this.cover(s.id),
});
async popularNovels(
pageNo: number,
{ showLatestNovels }: Plugin.PopularNovelsOptions,
): Promise<Plugin.NovelItem[]> {
const libraries = await this.bookLibraryIds();
if (!libraries.length) return [];
const series = await this.json<Series[]>(
`Series/all-v2?PageNumber=${pageNo}&PageSize=${PAGE_SIZE}`,
{
method: 'POST',
body: JSON.stringify({
statements: [
{ comparison: 5, field: 19, value: libraries.join(',') },
],
combination: 1,
// LastChapterAdded (newest first) for "latest", otherwise by name.
sortOptions: showLatestNovels
? { sortField: 4, isAscending: false }
: { sortField: 1, isAscending: true },
limitTo: 0,
}),
},
);
return series.map(this.toItem);
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
if (pageNo > 1) return [];
const [libraries, result] = await Promise.all([
this.bookLibraryIds(),
this.json<SearchResult>(
`Search/search?queryString=${encodeURIComponent(searchTerm)}&includeChapterAndFiles=false`,
),
]);
return (result.series || [])
.filter(s => libraries.includes(s.libraryId))
.map(s => this.toItem({ id: s.seriesId, name: s.name }));
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const [series, meta, volumes] = await Promise.all([
this.json<Series>(`Series/${novelPath}`),
this.json<Metadata>(`Series/metadata?seriesId=${novelPath}`),
this.json<Volume[]>(`Series/volumes?seriesId=${novelPath}`),
]);
const books = volumes.flatMap(v => v.chapters);
const chapters: Plugin.ChapterItem[] = [];
for (const book of books) {
const toc = await this.json<TocItem[]>(`Book/${book.id}/chapters`);
const titles = new Map<number, string>();
// Innermost entry wins: a "Part"/"Contents" parent often shares its
// page with the first chapter below it.
const walk = (items: TocItem[]) =>
items.forEach(item => {
if (item.children?.length) walk(item.children);
if (!titles.has(item.page)) titles.set(item.page, item.title);
});
walk(toc);
const prefix = books.length > 1 ? `${book.title} - ` : '';
let part = 0;
for (let page = 0; page < book.pages; page++) {
const title = titles.get(page);
chapters.push({
name: prefix + (title || `Part ${++part}`),
path: `${book.id}/${page}`,
chapterNumber: chapters.length + 1,
});
}
}
return {
path: novelPath,
name: series.name,
cover: this.cover(series.id),
author: meta.writers?.map(w => w.name).join(', '),
genres: meta.genres?.map(g => g.title).join(', '),
summary: meta.summary?.replace(/<[^>]+>/g, '').trim(),
status:
meta.publicationStatus !== undefined
? STATUS[meta.publicationStatus] || NovelStatus.Unknown
: NovelStatus.Unknown,
chapters,
};
}
async parseChapter(chapterPath: string): Promise<string> {
const [bookId, page] = chapterPath.split('/');
const html = await (
await this.request(`Book/${bookId}/book-page?page=${page}`)
).text();
const protocol = this.base.startsWith('http://') ? 'http:' : 'https:';
return (
html
// Kavita's reader stylesheet forces its own colours; let LNReader theme the text.
.replace(/<style[\s\S]*?<\/style>/gi, '')
// Resource URLs are protocol-relative (//host/api/book/...).
.replace(/((?:src|href|xlink:href)=["'])\/\//g, `$1${protocol}//`)
);
}
resolveUrl = (path: string, isNovel?: boolean) => {
const base = String(storage.get('url') || this.site).replace(/\/+$/, '');
return isNovel
? `${base}/library/0/series/${path}`
: `${base}/library/0/series/0/book/${path.split('/')[0]}`;
};
}
export default new KavitaPlugin();