From 3fbfadb8835eba65c81615269c00d84aff70609c Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 8 Oct 2026 08:37:37 +0800 Subject: [PATCH 1/2] feat(english): add BornToBeNovel source plugin Closes #2649 --- plugins/english/borntobenovel.ts | 301 ++++++++++++++++++++ public/static/src/en/borntobenovel/icon.png | Bin 0 -> 5735 bytes 2 files changed, 301 insertions(+) create mode 100644 plugins/english/borntobenovel.ts create mode 100644 public/static/src/en/borntobenovel/icon.png diff --git a/plugins/english/borntobenovel.ts b/plugins/english/borntobenovel.ts new file mode 100644 index 000000000..496d3cb23 --- /dev/null +++ b/plugins/english/borntobenovel.ts @@ -0,0 +1,301 @@ +import { load as parseHTML } from 'cheerio'; +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { Filters, FilterTypes } from '@libs/filterInputs'; +import { NovelStatus } from '@libs/novelStatus'; + +/** + * BornToBeNovel (borntobenovel.com) LNReader plugin + * + * A custom server-rendered site (not a known theme): + * + * - Catalog: `/library/` lists every novel as an `a.novel-card` with + * `data-genre`, `data-status` and `data-search-text`; the + * site filters and searches that one page client-side. + * - Ordering: `/static/sliders.json` holds the site's `popular` and + * `recent` (latest updates) slugs, used to order the catalog. + * - Novel page: `/novel//` — title, genre tags, status tag and + * description are in the markup; the chapter list is the + * inline `window.chapters = {...}` object + * (`{ "": { url, is_free, date } }`). + * - Chapter page: `/novel//chapters/ch-` — the text is a base64 + * string in `const contentData` that the page renders as a + * light markdown (`**bold**`, `*italic*`, `* * *` rules). + * Premium chapters carry a paywall notice instead. + */ + +const PREMIUM_NOTICE = 'This chapter is available only with premium'; + +function decodeBase64Utf8(b64: string): string { + const binary = atob(b64); + let percentEncoded = ''; + for (let i = 0; i < binary.length; i++) { + percentEncoded += + '%' + ('00' + binary.charCodeAt(i).toString(16)).slice(-2); + } + try { + return decodeURIComponent(percentEncoded); + } catch { + return binary; + } +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>'); +} + +function formatInline(text: string): string { + return escapeHtml(text) + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/(^|[^*])\*(?!\*)([^*]+?)\*(?!\*)/g, '$1$2'); +} + +function markdownToHtml(text: string): string { + return text + .split('\n') + .map(line => { + const trimmed = line.replace(/\r/g, '').trim(); + if (!trimmed) return ''; + if (/^\*\s*\*\s*\*$/.test(trimmed)) return '
'; + return ( + '

' + formatInline(trimmed.replace(/^~|~$/g, '').trim()) + '

' + ); + }) + .filter(Boolean) + .join('\n'); +} + +class BornToBeNovel implements Plugin.PluginBase { + id = 'borntobenovel'; + name = 'BornToBeNovel'; + icon = 'src/en/borntobenovel/icon.png'; + site = 'https://borntobenovel.com'; + version = '1.0.0'; + + private async fetchHtml(path: string) { + const res = await fetchApi(this.site + path); + if (!res.ok) { + throw new Error('Could not load ' + path + ' (HTTP ' + res.status + ')'); + } + return parseHTML(await res.text()); + } + + private async fetchCatalog(): Promise< + (Plugin.NovelItem & { genre: string; status: string; search: string })[] + > { + const $ = await this.fetchHtml('/library/'); + return $('a.novel-card') + .map((_, el) => { + const card = $(el); + const src = card.find('img.cover-icon').attr('src'); + return { + name: card.find('.novel-title').text().trim(), + path: card.attr('href') || '', + cover: src ? this.resolveAsset(src) : undefined, + genre: card.attr('data-genre') || '', + status: card.attr('data-status') || '', + search: (card.attr('data-search-text') || '').toLowerCase(), + }; + }) + .get() + .filter(novel => novel.path && novel.name); + } + + private resolveAsset(src: string) { + return /^https?:\/\//.test(src) ? src : this.site + src; + } + + async popularNovels( + pageNo: number, + { + showLatestNovels, + filters, + }: Plugin.PopularNovelsOptions, + ): Promise { + // The whole catalog is a single page. + if (pageNo > 1) return []; + const genre = filters?.genre?.value || 'all'; + const status = filters?.status?.value || 'all'; + const catalog = await this.fetchCatalog(); + const ranked = await this.fetchRanking( + showLatestNovels ? 'recent' : 'popular', + ); + const rank = (path: string) => { + const index = ranked.indexOf(path); + return index < 0 ? ranked.length : index; + }; + return catalog + .filter( + novel => + (genre === 'all' || novel.genre === genre) && + (status === 'all' || novel.status === status), + ) + .map((novel, index) => ({ novel, index })) + .sort( + (a, b) => rank(a.novel.path) - rank(b.novel.path) || a.index - b.index, + ) + .map(({ novel: { name, path, cover } }) => ({ name, path, cover })); + } + + private async fetchRanking(list: 'popular' | 'recent'): Promise { + try { + const res = await fetchApi(this.site + '/static/sliders.json'); + const data = await res.json(); + return (data[list] || []).map( + (item: { id: string }) => '/novel/' + item.id + '/', + ); + } catch { + return []; + } + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + if (pageNo > 1) return []; + const term = searchTerm.trim().toLowerCase(); + return (await this.fetchCatalog()) + .filter(novel => novel.search.includes(term)) + .map(({ name, path, cover }) => ({ name, path, cover })); + } + + async parseNovel(novelPath: string): Promise { + const path = novelPath.endsWith('/') ? novelPath : novelPath + '/'; + const res = await fetchApi(this.site + path); + if (!res.ok) { + throw new Error('Could not load ' + path + ' (HTTP ' + res.status + ')'); + } + const html = await res.text(); + if (!html.trim()) { + throw new Error('The site returned an empty page for ' + path); + } + const $ = parseHTML(html); + + const title = $('h1.desktop-title').first().clone(); + title.find('.alt-names').remove(); + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: title.text().trim() || 'Untitled', + }; + + const cover = $('img.desktop-cover').attr('src'); + if (cover) novel.cover = this.resolveAsset(cover); + + const tags = $('#desktopGenres .genre-tag'); + const statusClass = tags.filter('.status-tag').attr('class') || ''; + if (statusClass.includes('status-completed')) { + novel.status = NovelStatus.Completed; + } else if (statusClass.includes('status-ongoing')) { + novel.status = NovelStatus.Ongoing; + } else if (statusClass.includes('status-dropped')) { + novel.status = NovelStatus.Cancelled; + } + + novel.genres = tags + .filter( + (_, el) => + !$(el).is('.rating-tag, .status-tag, .chapters-tag') && + !$(el).hasClass('genre-more-button'), + ) + .map((_, el) => $(el).text().trim()) + .get() + .filter(Boolean) + .join(', '); + + const summary = $('.desktop-description .novel-description').first(); + summary.find('br').replaceWith('\n'); + novel.summary = summary.text().trim() || undefined; + + novel.chapters = this.parseChapterList(html); + return novel; + } + + private parseChapterList(html: string): Plugin.ChapterItem[] { + const marker = 'window.chapters = '; + const start = html.indexOf(marker); + if (start < 0) return []; + const begin = html.indexOf('{', start); + // The object is flat JSON that ends at the first `};` after it. + const end = html.indexOf('};', begin); + if (begin < 0 || end < 0) return []; + + let data: Record; + try { + data = JSON.parse(html.slice(begin, end + 1)); + } catch { + return []; + } + + return Object.keys(data) + .map(Number) + .filter(n => !isNaN(n)) + .sort((a, b) => a - b) + .map(n => { + const item = data[String(n)]; + const [day, month, year] = (item.date || '').split('.'); + return { + name: 'Chapter ' + n + (item.is_free === false ? ' (Premium)' : ''), + path: item.url, + releaseTime: year ? year + '-' + month + '-' + day : undefined, + chapterNumber: n, + }; + }); + } + + async parseChapter(chapterPath: string): Promise { + const res = await fetchApi(this.site + chapterPath); + if (!res.ok) { + throw new Error( + 'Could not load chapter (HTTP ' + res.status + '): ' + chapterPath, + ); + } + const html = await res.text(); + const match = html.match(/const contentData = '([^']*)'/); + if (!match || !match[1]) { + throw new Error('Chapter text not found: ' + chapterPath); + } + const text = decodeBase64Utf8(match[1]); + if (text.startsWith(PREMIUM_NOTICE)) { + throw new Error( + 'Premium chapter: a BornToBeNovel subscription is required to read it.', + ); + } + return markdownToHtml(text); + } + + resolveUrl = (path: string, isNovel?: boolean) => + this.site + path + (isNovel && !path.endsWith('/') ? '/' : ''); + + filters = { + genre: { + label: 'Genre', + value: 'all', + options: [ + { label: 'All', value: 'all' }, + { label: 'Yaoi', value: 'Yaoi' }, + { label: 'Erotica', value: 'Erotica' }, + { label: 'Shounen', value: 'Shounen' }, + { label: 'Shoujo', value: 'Shoujo' }, + ], + type: FilterTypes.Picker, + }, + status: { + label: 'Status', + value: 'all', + options: [ + { label: 'All', value: 'all' }, + { label: 'Ongoing', value: 'ongoing' }, + { label: 'Completed', value: 'completed' }, + { label: 'Dropped', value: 'dropped' }, + ], + type: FilterTypes.Picker, + }, + } satisfies Filters; +} + +export default new BornToBeNovel(); diff --git a/public/static/src/en/borntobenovel/icon.png b/public/static/src/en/borntobenovel/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..db0b2c190dc879415f0b74f93bba44b92bfc11fd GIT binary patch literal 5735 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 fVd4KDEO9#Q70vY?SUkAbf7iLuR{L^P>Sw% literal 0 HcmV?d00001 From d0e43b2e2bf45a3f4857cee4fec6ef77491952c4 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 8 Oct 2026 08:50:54 +0800 Subject: [PATCH 2/2] no-mistakes(ci): Added a shared fetchPage helper in plugins/english/borntobenovel.ts. It sends browser-like headers (User-Agent, Accept, Accept-Language, Referer = site) and throws an Error with a numeric `status` on any non-ok response. fetchHtml, parseNovel and parseChapter all use it. Prettier and ESLint are clean, and check:plugin passes all four checks locally. I could not reproduce the CI 403 here, so I haven't confirmed the CI check now reports INCONCLUSIVE. Shared tooling, workflow and version are untouched --- plugins/english/borntobenovel.ts | 39 +++++++++++++++++++------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/plugins/english/borntobenovel.ts b/plugins/english/borntobenovel.ts index 496d3cb23..05dec0685 100644 --- a/plugins/english/borntobenovel.ts +++ b/plugins/english/borntobenovel.ts @@ -75,12 +75,29 @@ class BornToBeNovel implements Plugin.PluginBase { site = 'https://borntobenovel.com'; version = '1.0.0'; - private async fetchHtml(path: string) { - const res = await fetchApi(this.site + path); + private async fetchPage(path: string): Promise { + const res = await fetchApi(this.site + path, { + headers: { + 'User-Agent': + 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + 'Accept': + 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'Referer': this.site + '/', + }, + }); if (!res.ok) { - throw new Error('Could not load ' + path + ' (HTTP ' + res.status + ')'); + const error = new Error( + 'Could not load ' + path + ' (HTTP ' + res.status + ')', + ) as Error & { status: number }; + error.status = res.status; + throw error; } - return parseHTML(await res.text()); + return res.text(); + } + + private async fetchHtml(path: string) { + return parseHTML(await this.fetchPage(path)); } private async fetchCatalog(): Promise< @@ -165,11 +182,7 @@ class BornToBeNovel implements Plugin.PluginBase { async parseNovel(novelPath: string): Promise { const path = novelPath.endsWith('/') ? novelPath : novelPath + '/'; - const res = await fetchApi(this.site + path); - if (!res.ok) { - throw new Error('Could not load ' + path + ' (HTTP ' + res.status + ')'); - } - const html = await res.text(); + const html = await this.fetchPage(path); if (!html.trim()) { throw new Error('The site returned an empty page for ' + path); } @@ -248,13 +261,7 @@ class BornToBeNovel implements Plugin.PluginBase { } async parseChapter(chapterPath: string): Promise { - const res = await fetchApi(this.site + chapterPath); - if (!res.ok) { - throw new Error( - 'Could not load chapter (HTTP ' + res.status + '): ' + chapterPath, - ); - } - const html = await res.text(); + const html = await this.fetchPage(chapterPath); const match = html.match(/const contentData = '([^']*)'/); if (!match || !match[1]) { throw new Error('Chapter text not found: ' + chapterPath);