From 781d7c35d4f10d803332ca1e03ec9628f6fa385e Mon Sep 17 00:00:00 2001 From: RevDev Date: Thu, 22 Oct 2026 13:41:06 +0300 Subject: [PATCH 1/2] Add BoonNovel icon --- public/static/src/en/boonnovel/icon.png | Bin 0 -> 18366 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 public/static/src/en/boonnovel/icon.png diff --git a/public/static/src/en/boonnovel/icon.png b/public/static/src/en/boonnovel/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9d844121c4219042bc3e413586856f69d17569f5 GIT binary patch literal 18366 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 Z{~v Date: Thu, 22 Oct 2026 13:41:36 +0300 Subject: [PATCH 2/2] feat(english): add BoonNovel source --- plugins/english/boonnovel.ts | 472 +++++++++++++++++++++++++++++++++++ 1 file changed, 472 insertions(+) create mode 100644 plugins/english/boonnovel.ts diff --git a/plugins/english/boonnovel.ts b/plugins/english/boonnovel.ts new file mode 100644 index 000000000..4c464211e --- /dev/null +++ b/plugins/english/boonnovel.ts @@ -0,0 +1,472 @@ +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { NovelStatus } from '@libs/novelStatus'; + +// BoonNovel — https://reader.boondollars.com +// A self-hosted English library of AI-translated novels (occasionally +// human-edited), served over a clean public JSON API with no login: +// GET /api/public/books full catalog (105 books) +// GET /api/public/books/ book detail +// GET /api/public/books//chapters chapter list +// GET /api/public/books//chapters/ chapter paragraphs +// The catalog call returns everything and ignores paging parameters, +// so popular/latest/search all work off that one list. Chapter bodies +// arrive as plain-text paragraphs, wrapped into

HTML (escaped — +// the paragraphs are raw text, not HTML). + +const SITE = 'https://reader.boondollars.com'; + +/** + * Upper bound on a response this plugin will parse. The biggest + * payload is a ~1,900-chapter list (a few hundred KB); anything far + * above legitimate responses is a broken or hostile payload, and + * parsing it would bill the reader's phone. + */ +const MAX_RESPONSE_CHARS = 8000000; + +/** + * Error with the HTTP status attached. Tooling (e.g. the repo's live + * check) reads `error.status` to tell a site block or outage (403/503) + * apart from a plugin bug, so always throw fetch failures this way. + */ +function httpError(message: string, status: number): Error { + const err = new Error(message) as Error & { status?: number }; + err.status = status; + return err; +} + +async function fetchJson(url: string, what: string): Promise { + const res = await fetchApi(url); + if (!res.ok) { + throw httpError(`${what}: HTTP ${res.status}`, res.status); + } + const text = await res.text(); + if (text.length > MAX_RESPONSE_CHARS) { + throw new Error(`${what}: response too large (${text.length} chars)`); + } + let data: unknown; + try { + data = JSON.parse(text); + } catch { + throw new Error(`${what}: response was not valid JSON`); + } + if (!data || typeof data !== 'object') { + throw new Error(`${what}: response had an unexpected shape`); + } + return data; +} + +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +function asNumber(v: unknown, what: string): number { + if (typeof v !== 'number' || isNaN(v) || !isFinite(v)) { + throw new Error('Boonnovels response is missing a valid ' + what); + } + return v; +} + +function asString(v: unknown, what: string): string { + if (typeof v !== 'string') { + throw new Error('Boonnovels response is missing a valid ' + what); + } + return v; +} + +function asStringArray(v: unknown, what: string): string[] { + if (!Array.isArray(v)) { + throw new Error('Boonnovels response is missing a valid ' + what); + } + const out: string[] = []; + for (const item of v) { + if (typeof item === 'string') out.push(item); + } + return out; +} + +type BookSummary = { + id: number; + title: string; + author: string; + cover?: string; + status: string; + tags: string[]; + recentViews: number; + lastChapterDate: string; +}; + +type BookDetail = { + id: number; + title: string; + author: string; + description: string; + cover?: string; + status: string; + tags: string[]; +}; + +type ChapterRef = { + number: number; + title: string; +}; + +// Prefer the medium cover (the grid shows covers at about that size), +// then the full cover, then the thumbnail. All three are null for a +// few books, in which case there is no cover to return. +function pickCover(rec: Record): string | undefined { + const medium = rec['cover_medium_url']; + if (typeof medium === 'string' && medium) return medium; + const full = rec['cover_url']; + if (typeof full === 'string' && full) return full; + const thumb = rec['cover_thumb_url']; + if (typeof thumb === 'string' && thumb) return thumb; + return undefined; +} + +function parseBookSummary(rec: unknown): BookSummary { + if (!isRecord(rec)) { + throw new Error('Boonnovels book entry is not an object'); + } + return { + id: asNumber(rec['id'], 'book id'), + title: asString(rec['title'], 'book title'), + author: typeof rec['author'] === 'string' ? rec['author'] : '', + cover: pickCover(rec), + status: asString(rec['status'], 'book status'), + tags: asStringArray(rec['tags'], 'book tags'), + recentViews: + typeof rec['recent_views'] === 'number' ? rec['recent_views'] : 0, + lastChapterDate: + typeof rec['last_chapter_date'] === 'string' + ? rec['last_chapter_date'] + : '', + }; +} + +function parseBooksList(data: unknown): BookSummary[] { + const res = data as { books?: unknown }; + if (!Array.isArray(res.books)) { + throw new Error('Boonnovels book list is missing its books array'); + } + const books: BookSummary[] = []; + for (const entry of res.books) { + books.push(parseBookSummary(entry)); + } + return books; +} + +function parseBookDetail(data: unknown): BookDetail { + if (!isRecord(data)) { + throw new Error('Boonnovels book detail is not an object'); + } + return { + id: asNumber(data['id'], 'book id'), + title: asString(data['title'], 'book title'), + author: typeof data['author'] === 'string' ? data['author'] : '', + description: + typeof data['description'] === 'string' ? data['description'] : '', + cover: pickCover(data), + status: asString(data['status'], 'book status'), + tags: asStringArray(data['tags'], 'book tags'), + }; +} + +function parseChaptersList(data: unknown): ChapterRef[] { + const res = data as { chapters?: unknown }; + if (!Array.isArray(res.chapters)) { + throw new Error('Boonnovels chapter list is missing its chapters array'); + } + const chapters: ChapterRef[] = []; + for (const item of res.chapters) { + if (!isRecord(item)) { + throw new Error('Boonnovels chapter entry is not an object'); + } + // The number is load-bearing (dedupe, ordering, chapter paths), + // so a bad one throws. A missing title only falls back to the + // plain "Chapter N" name. + chapters.push({ + number: asNumber(item['chapter'], 'chapter number'), + title: typeof item['title'] === 'string' ? item['title'] : '', + }); + } + return chapters; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// Chapter bodies arrive as an array of paragraphs (empty strings mark +// paragraph breaks). Empty paragraphs are skipped; if nothing readable +// remains, the chapter is a stub, not an empty chapter. +function parseChapterBody(data: unknown): string { + const res = data as { content?: unknown }; + if (!Array.isArray(res.content)) { + throw new Error('Boonnovels chapter is missing its content array'); + } + const parts: string[] = []; + for (const para of res.content) { + if (typeof para !== 'string') { + throw new Error('Boonnovels chapter paragraph is not a string'); + } + if (para.replace(/^\s+|\s+$/g, '') !== '') { + parts.push('

' + escapeHtml(para) + '

'); + } + } + if (parts.length === 0) { + throw new Error('Boonnovels chapter has no readable content'); + } + return parts.join('\n'); +} + +// Site statuses: ongoing, ongoing-trial, completed, dropped, hiatus. +// Anything else maps to Unknown, never to a guessed status. +function mapStatus(siteStatus: string): string { + if (siteStatus === 'ongoing' || siteStatus === 'ongoing-trial') { + return NovelStatus.Ongoing; + } + if (siteStatus === 'completed') return NovelStatus.Completed; + if (siteStatus === 'dropped') return NovelStatus.Cancelled; + if (siteStatus === 'hiatus') return NovelStatus.OnHiatus; + return NovelStatus.Unknown; +} + +// The catalog endpoint returns the whole catalog in one call and +// ignores paging parameters, so popular/latest are sorts over that +// list and every page past 1 is empty. +function sortPopular(books: BookSummary[]): BookSummary[] { + const sorted = books.slice(); + sorted.sort((a, b) => b.recentViews - a.recentViews); + return sorted; +} + +function sortLatest(books: BookSummary[]): BookSummary[] { + const sorted = books.slice(); + sorted.sort((a, b) => { + const da = Date.parse(a.lastChapterDate); + const db = Date.parse(b.lastChapterDate); + const ta = isNaN(da) ? 0 : da; + const tb = isNaN(db) ? 0 : db; + return tb - ta; + }); + return sorted; +} + +function searchBooks(books: BookSummary[], term: string): BookSummary[] { + const needle = term.replace(/^\s+|\s+$/g, '').toLowerCase(); + if (needle === '') return []; + const out: BookSummary[] = []; + for (const b of books) { + if ( + b.title.toLowerCase().indexOf(needle) !== -1 || + b.author.toLowerCase().indexOf(needle) !== -1 + ) { + out.push(b); + } + } + return out; +} + +// Paths are virtual (the site's reader URLs are /read//, +// but the plugin's own paths keep the book id and chapter number +// unambiguous): novel /book/, chapter /book//chapter/. +function buildNovelPath(bookId: number): string { + return '/book/' + bookId; +} + +function buildChapterPath(bookId: number, chapter: number): string { + return '/book/' + bookId + '/chapter/' + chapter; +} + +function stripQuery(path: string): string { + const q = path.indexOf('?'); + return q === -1 ? path : path.slice(0, q); +} + +function parseIdSegment(seg: string, what: string): number { + if (!/^\d+$/.test(seg)) { + throw new Error('Invalid Boonnovels ' + what + ' in path'); + } + const n = parseInt(seg, 10); + if (isNaN(n) || !isFinite(n)) { + throw new Error('Invalid Boonnovels ' + what + ' in path'); + } + return n; +} + +function parseNovelPath(novelPath: string): number { + const parts = stripQuery(novelPath).split('/'); + if (parts.length !== 3 || parts[1] !== 'book') { + throw new Error('Invalid Boonnovels novel path: ' + novelPath); + } + return parseIdSegment(parts[2], 'book id'); +} + +function parseChapterPath(chapterPath: string): { + bookId: number; + chapter: number; +} { + const parts = stripQuery(chapterPath).split('/'); + if (parts.length !== 5 || parts[1] !== 'book' || parts[3] !== 'chapter') { + throw new Error('Invalid Boonnovels chapter path: ' + chapterPath); + } + return { + bookId: parseIdSegment(parts[2], 'book id'), + chapter: parseIdSegment(parts[4], 'chapter number'), + }; +} + +function chapterName(ref: ChapterRef): string { + if (ref.title !== '') return 'Chapter ' + ref.number + ': ' + ref.title; + return 'Chapter ' + ref.number; +} + +function buildChapters( + refs: ChapterRef[], + bookId: number, +): Plugin.ChapterItem[] { + const sorted = refs.slice(); + sorted.sort((a, b) => a.number - b.number); + const seen: Record = {}; + const chapters: Plugin.ChapterItem[] = []; + for (const ref of sorted) { + if (seen[ref.number]) continue; + seen[ref.number] = true; + chapters.push({ + name: chapterName(ref), + path: buildChapterPath(bookId, ref.number), + chapterNumber: ref.number, + }); + } + return chapters; +} + +function toNovelItem(b: BookSummary): Plugin.NovelItem { + const item: Plugin.NovelItem = { + name: b.title, + path: buildNovelPath(b.id), + }; + if (b.cover !== undefined) item.cover = b.cover; + return item; +} + +class BoonNovel implements Plugin.PluginBase { + id = 'boonnovel'; + name = 'BoonNovel'; + icon = 'src/en/boonnovel/icon.png'; + site = 'https://reader.boondollars.com'; + version = '1.0.0'; + + async popularNovels( + pageNo: number, + options: Plugin.PopularNovelsOptions, + ): Promise { + if (pageNo !== 1) return []; + const data = await fetchJson( + `${SITE}/api/public/books`, + 'Failed to load popular novels', + ); + const books = parseBooksList(data); + const sorted = + options && options.showLatestNovels + ? sortLatest(books) + : sortPopular(books); + return sorted.map(b => toNovelItem(b)); + } + + async parseNovel(novelPath: string): Promise { + const bookId = parseNovelPath(novelPath); + const detail = parseBookDetail( + await fetchJson( + `${SITE}/api/public/books/${bookId}`, + 'Failed to load novel', + ), + ); + const refs = parseChaptersList( + await fetchJson( + `${SITE}/api/public/books/${bookId}/chapters`, + 'Failed to load chapter list', + ), + ); + const novel: Plugin.SourceNovel = { + path: novelPath, + name: detail.title, + author: detail.author ? detail.author : undefined, + summary: detail.description ? detail.description : undefined, + status: mapStatus(detail.status), + genres: detail.tags.length ? detail.tags.join(', ') : undefined, + chapters: buildChapters(refs, bookId), + }; + if (detail.cover !== undefined) novel.cover = detail.cover; + return novel; + } + + // The app opens a novel already saved in its database but with no + // stored chapters through parsePage, not parseNovel — and it throws + // when the plugin has none. This plugin has no server-side chapter + // paging (the app pages the full list itself), so every page is the + // whole list. + async parsePage(novelPath: string, page: string): Promise { + void page; + const novel = await this.parseNovel(novelPath); + return { chapters: novel.chapters || [] }; + } + + async parseChapter(chapterPath: string): Promise { + const parsed = parseChapterPath(chapterPath); + const content = parseChapterBody( + await fetchJson( + `${SITE}/api/public/books/${parsed.bookId}/chapters/${parsed.chapter}`, + 'Failed to load chapter', + ), + ); + if (!content) { + throw new Error('Chapter content not found'); + } + return content; + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + if (pageNo !== 1) return []; + const data = await fetchJson( + `${SITE}/api/public/books`, + 'Failed to search novels', + ); + const books = parseBooksList(data); + return searchBooks(books, searchTerm).map(b => toNovelItem(b)); + } + + // resolveUrl MUST pass absolute URLs through unchanged: the app + // also passes the novel's cover URL through here (EPUB export does + // this), and prefixing the site onto an absolute cover breaks it. + // Virtual plugin paths map onto the site's real routes: a novel + // opens at /library/book/, a chapter reads at /read//. + resolveUrl = (path: string) => { + if (/^https?:\/\//i.test(path)) return path; + if (path.indexOf('//') === 0) return 'https:' + path; + try { + const parsed = parseChapterPath(path); + return `${SITE}/read/${parsed.bookId}/${parsed.chapter}`; + } catch { + // not a chapter path, try the novel path below + } + try { + const bookId = parseNovelPath(path); + return `${SITE}/library/book/${bookId}`; + } catch { + // not a plugin path either, fall through to the plain prefix + } + return this.site + path; + }; +} + +export default new BoonNovel();