From a00984176e68d7b84b981b5019562c03d8562c84 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Tue, 27 Oct 2026 11:59:45 +0800 Subject: [PATCH 1/3] fix(english/novelbin): move Novel Bin to novelbin.xyz JSON API Novel Bin now runs on novelbin.xyz, a single-page app that loads its data as JSON. novelbin.com, which the readnovelfull entry targeted, returns HTTP 503, and chapters of novels such as "I Got My Cheat Skill by Acting My Way into a Horror Protagonist Role" failed with "Chapter not found (404)". Replace the readnovelfull entry with a standalone plugin that reads the site's own /api/cdn/ JSON route: popular, latest, genre and status listings, novel details, chapter list, chapter text, and search over the site's search index (loaded once per session). The id stays "novelbin" and the version moves to 3.0.0. Paths saved by the old plugin (b/ and b//) still resolve, since the novel slugs and chapter title slugs match. Fixes #2791 --- plugins/english/novelbin.ts | 315 ++++++++++++++++++ plugins/multisrc/readnovelfull/sources.json | 11 - .../en}/novelbin/icon.png | Bin 3 files changed, 315 insertions(+), 11 deletions(-) create mode 100644 plugins/english/novelbin.ts rename public/static/{multisrc/readnovelfull => src/en}/novelbin/icon.png (100%) diff --git a/plugins/english/novelbin.ts b/plugins/english/novelbin.ts new file mode 100644 index 000000000..b09de9513 --- /dev/null +++ b/plugins/english/novelbin.ts @@ -0,0 +1,315 @@ +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { Filters, FilterTypes } from '@libs/filterInputs'; +import { defaultCover } from '@libs/defaultCover'; +import { NovelStatus } from '@libs/novelStatus'; + +type ListNovel = { + title: string; + slug: string; + cover?: string; +}; + +type ListPage = { + page: number; + has_more: boolean; + data: ListNovel[]; +}; + +type SearchEntry = ListNovel & { + alternative_titles?: string; +}; + +type NovelDetail = { + id: number; + title: string; + slug: string; + author?: string; + description?: string; + cover?: string; + genres?: string[]; + status?: string; +}; + +type ChapterEntry = { + title: string; + index: number; +}; + +type ChapterContent = { + title?: string; + content?: string; +}; + +const searchPageSize = 50; + +class NovelBinPlugin implements Plugin.PluginBase { + id = 'novelbin'; + name = 'Novel Bin'; + icon = 'src/en/novelbin/icon.png'; + site = 'https://novelbin.xyz/'; + version = '3.0.0'; + + // The site's JSON lives on a CDN that refuses requests from other origins; + // the site's own /api/cdn/ route serves the same files. + apiBase = this.site + 'api/cdn/'; + + private searchIndex: SearchEntry[] | null = null; + private novelIds: Record = {}; + + filters = { + genre: { + type: FilterTypes.Picker, + label: 'Genre', + value: '', + options: [ + { label: 'All', value: '' }, + { label: 'Action', value: 'action' }, + { label: 'Adventure', value: 'adventure' }, + { label: 'Comedy', value: 'comedy' }, + { label: 'Drama', value: 'drama' }, + { label: 'Eastern', value: 'eastern' }, + { label: 'Fan Fiction', value: 'fan-fiction' }, + { label: 'Fantasy', value: 'fantasy' }, + { label: 'Game', value: 'game' }, + { label: 'Harem', value: 'harem' }, + { label: 'Historical', value: 'historical' }, + { label: 'Horror', value: 'horror' }, + { label: 'Isekai', value: 'isekai' }, + { label: 'Josei', value: 'josei' }, + { label: 'Litrpg', value: 'litrpg' }, + { label: 'Magic', value: 'magic' }, + { label: 'Magical Realism', value: 'magical-realism' }, + { label: 'Martial Arts', value: 'martial-arts' }, + { label: 'Mature', value: 'mature' }, + { label: 'Mecha', value: 'mecha' }, + { label: 'Military', value: 'military' }, + { label: 'Modern Life', value: 'modern-life' }, + { label: 'Mystery', value: 'mystery' }, + { label: 'Other', value: 'other' }, + { label: 'Psychological', value: 'psychological' }, + { label: 'Reincarnation', value: 'reincarnation' }, + { label: 'Romance', value: 'romance' }, + { label: 'School Life', value: 'school-life' }, + { label: 'Sci Fi', value: 'sci-fi' }, + { label: 'Seinen', value: 'seinen' }, + { label: 'Shoujo', value: 'shoujo' }, + { label: 'Shoujo Ai', value: 'shoujo-ai' }, + { label: 'Shounen', value: 'shounen' }, + { label: 'Shounen Ai', value: 'shounen-ai' }, + { label: 'Slice Of Life', value: 'slice-of-life' }, + { label: 'Sports', value: 'sports' }, + { label: 'Supernatural', value: 'supernatural' }, + { label: 'System', value: 'system' }, + { label: 'Thriller', value: 'thriller' }, + { label: 'Tragedy', value: 'tragedy' }, + { label: 'Urban', value: 'urban' }, + { label: 'War', value: 'war' }, + { label: 'Wuxia', value: 'wuxia' }, + { label: 'Xianxia', value: 'xianxia' }, + { label: 'Xuanhuan', value: 'xuanhuan' }, + { label: 'Yaoi', value: 'yaoi' }, + { label: 'Yuri', value: 'yuri' }, + ], + }, + status: { + type: FilterTypes.Picker, + label: 'Status (ignored when a genre is selected)', + value: '', + options: [ + { label: 'All', value: '' }, + { label: 'Ongoing', value: 'ongoing' }, + { label: 'Completed', value: 'completed' }, + ], + }, + } satisfies Filters; + + async fetchJson(file: string): Promise { + const response = await fetchApi(this.apiBase + file); + // Missing files (unknown slugs, pages past the end) come back as 403/404. + if (!response.ok) return null; + return (await response.json()) as T; + } + + // The site reads `web_` lists first and falls back to the plain ones. + async fetchList(name: string): Promise { + return ( + (await this.fetchJson(`lists/web_${name}.json`)) || + this.fetchJson(`lists/${name}.json`) + ); + } + + toNovelItem(novel: ListNovel): Plugin.NovelItem { + return { + name: novel.title, + path: 'novel/' + novel.slug, + cover: novel.cover || defaultCover, + }; + } + + async popularNovels( + pageNo: number, + { + showLatestNovels, + filters, + }: Plugin.PopularNovelsOptions, + ): Promise { + let listName = ''; + if (filters?.genre.value) { + listName = `genre_${filters.genre.value}_${pageNo}`; + } else if (filters?.status.value) { + listName = `status_${filters.status.value}_${pageNo}`; + } else if (showLatestNovels) { + listName = `latest_${pageNo}`; + } + + if (!listName) { + // The popular list is a single, unpaged list. + if (pageNo > 1) return []; + const popular = await this.fetchList('popular'); + return (popular || []).map(novel => this.toNovelItem(novel)); + } + + const page = await this.fetchList(listName); + return (page?.data || []).map(novel => this.toNovelItem(novel)); + } + + // Accepts current paths (novel/, read//chapter-) and paths + // saved by the previous novelbin.com plugin (b/, b//). + getSlug(path: string): string { + return path.replace(/^\//, '').split('/')[1]; + } + + async getNovel(slug: string): Promise { + const novel = await this.fetchJson(`novel/${slug}.json`); + if (!novel) { + throw new Error( + 'Novel not found, it may have been removed from this source.', + ); + } + this.novelIds[slug] = novel.id; + return novel; + } + + async getChapterList(novelId: number): Promise { + const chapters = await this.fetchJson( + `chapters/${novelId}.json`, + ); + return chapters || []; + } + + async parseNovel(novelPath: string): Promise { + const slug = this.getSlug(novelPath); + const detail = await this.getNovel(slug); + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: detail.title, + cover: detail.cover || defaultCover, + author: detail.author, + summary: detail.description, + genres: (detail.genres || []).join(', '), + }; + + if (detail.status === 'Ongoing') novel.status = NovelStatus.Ongoing; + else if (detail.status === 'Completed') { + novel.status = NovelStatus.Completed; + } + + const chapters = await this.getChapterList(detail.id); + novel.chapters = chapters + .sort((a, b) => a.index - b.index) + .map(chapter => ({ + name: chapter.title, + path: `read/${slug}/chapter-${chapter.index + 1}`, + chapterNumber: chapter.index + 1, + })); + + return novel; + } + + // Old novelbin.com chapter paths end in a slug of the chapter title, + // e.g. "chapter-10-first-man-down"; find that chapter's index here. + async findLegacyChapterIndex( + novelId: number, + chapterSlug: string, + ): Promise { + const toSlug = (title: string) => + title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + const chapters = await this.getChapterList(novelId); + + const bySlug = chapters.find( + chapter => toSlug(chapter.title) === chapterSlug, + ); + if (bySlug) return bySlug.index; + + const chapterNumber = chapterSlug.match(/^chapter-(\d+)/)?.[1]; + const byNumber = chapterNumber + ? chapters.find( + chapter => + toSlug(chapter.title).match(/^chapter-(\d+)/)?.[1] === + chapterNumber, + ) + : undefined; + if (byNumber) return byNumber.index; + + throw new Error( + 'Chapter not found, refresh the novel to load its current chapter list.', + ); + } + + async parseChapter(chapterPath: string): Promise { + const slug = this.getSlug(chapterPath); + const chapterSlug = chapterPath.split('/').pop() || ''; + const novelId = this.novelIds[slug] ?? (await this.getNovel(slug)).id; + + const index = chapterPath.startsWith('read/') + ? Number(chapterSlug.replace('chapter-', '')) - 1 + : await this.findLegacyChapterIndex(novelId, chapterSlug); + + const chapter = await this.fetchJson( + `read/${novelId}/${index}.json`, + ); + if (!chapter?.content) { + throw new Error( + 'Chapter not found, it may have been removed from this source.', + ); + } + return chapter.content; + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + // The site searches one ~7.5 MB index on the client; load it once. + if (!this.searchIndex) { + this.searchIndex = await this.fetchList('search'); + if (!this.searchIndex) + throw new Error('Could not load the search index.'); + } + + const term = searchTerm.toLowerCase().trim(); + return this.searchIndex + .filter( + novel => + novel.title.toLowerCase().includes(term) || + (novel.alternative_titles || '').toLowerCase().includes(term), + ) + .slice((pageNo - 1) * searchPageSize, pageNo * searchPageSize) + .map(novel => this.toNovelItem(novel)); + } + + resolveUrl = (path: string) => { + // Old novelbin.com paths have no page on this site; open the novel instead. + if (path.startsWith('b/')) { + return this.site + 'novel/' + this.getSlug(path); + } + return this.site + path; + }; +} + +export default new NovelBinPlugin(); diff --git a/plugins/multisrc/readnovelfull/sources.json b/plugins/multisrc/readnovelfull/sources.json index 418423efd..19cc6e203 100644 --- a/plugins/multisrc/readnovelfull/sources.json +++ b/plugins/multisrc/readnovelfull/sources.json @@ -72,17 +72,6 @@ "listClass": "fwn-archive-main" } }, - { - "id": "novelbin", - "sourceSite": "https://novelbin.com/", - "sourceName": "Novel Bin", - "options": { - "latestPage": "sort/latest", - "searchPage": "search", - "versionIncrements": 1, - "customJs": "$('[class*=\"app-promo\"]').remove();" - } - }, { "id": "novelping", "sourceSite": "https://novelping.com/", diff --git a/public/static/multisrc/readnovelfull/novelbin/icon.png b/public/static/src/en/novelbin/icon.png similarity index 100% rename from public/static/multisrc/readnovelfull/novelbin/icon.png rename to public/static/src/en/novelbin/icon.png From 86e2bdc8a976afd5ebe2fec79778a7355bc67a04 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Tue, 27 Oct 2026 12:32:23 +0800 Subject: [PATCH 2/3] fix(english/novelbin): keep absolute URLs, report outages, share search download - resolveUrl returns http(s) URLs unchanged, so covers and defaultCover survive EPUB export; old b/ paths still map to the novel page. - fetchJson returns null only for 403/404 (missing files, which keeps the web_ -> plain list fallback); other errors such as 429 or 5xx now throw with the status instead of showing an empty catalog. - searchNovels caches the pending search-index download so overlapping first searches fetch the ~7.5 MB index once, and clears it on failure so the next search retries. --- plugins/english/novelbin.ts | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/plugins/english/novelbin.ts b/plugins/english/novelbin.ts index b09de9513..93cbeb480 100644 --- a/plugins/english/novelbin.ts +++ b/plugins/english/novelbin.ts @@ -54,7 +54,7 @@ class NovelBinPlugin implements Plugin.PluginBase { // the site's own /api/cdn/ route serves the same files. apiBase = this.site + 'api/cdn/'; - private searchIndex: SearchEntry[] | null = null; + private searchIndex: Promise | null = null; private novelIds: Record = {}; filters = { @@ -127,7 +127,12 @@ class NovelBinPlugin implements Plugin.PluginBase { async fetchJson(file: string): Promise { const response = await fetchApi(this.apiBase + file); // Missing files (unknown slugs, pages past the end) come back as 403/404. - if (!response.ok) return null; + if (response.status === 403 || response.status === 404) return null; + if (!response.ok) { + throw new Error( + `Could not reach site (${response.status}: ${response.statusText}) try to open in webview.`, + ); + } return (await response.json()) as T; } @@ -285,15 +290,22 @@ class NovelBinPlugin implements Plugin.PluginBase { searchTerm: string, pageNo: number, ): Promise { - // The site searches one ~7.5 MB index on the client; load it once. + // The site searches one ~7.5 MB index on the client; load it once, and + // share the pending download between overlapping searches. if (!this.searchIndex) { - this.searchIndex = await this.fetchList('search'); - if (!this.searchIndex) - throw new Error('Could not load the search index.'); + this.searchIndex = this.fetchList('search').then(index => { + if (!index) throw new Error('Could not load the search index.'); + return index; + }); + // Let a later search retry after a failed download. + this.searchIndex.catch(() => { + this.searchIndex = null; + }); } + const searchIndex = await this.searchIndex; const term = searchTerm.toLowerCase().trim(); - return this.searchIndex + return searchIndex .filter( novel => novel.title.toLowerCase().includes(term) || @@ -304,6 +316,8 @@ class NovelBinPlugin implements Plugin.PluginBase { } resolveUrl = (path: string) => { + // Covers (and defaultCover) are already absolute. + if (/^https?:\/\//i.test(path)) return path; // Old novelbin.com paths have no page on this site; open the novel instead. if (path.startsWith('b/')) { return this.site + 'novel/' + this.getSlug(path); From 266c8106682a8568163141c612753f5253fa259f Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Tue, 27 Oct 2026 12:33:42 +0800 Subject: [PATCH 3/3] no-mistakes(review): detect Cloudflare challenge in novelbin fetchJson --- plugins/english/novelbin.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plugins/english/novelbin.ts b/plugins/english/novelbin.ts index 93cbeb480..ea00895e8 100644 --- a/plugins/english/novelbin.ts +++ b/plugins/english/novelbin.ts @@ -126,6 +126,15 @@ class NovelBinPlugin implements Plugin.PluginBase { async fetchJson(file: string): Promise { const response = await fetchApi(this.apiBase + file); + const contentType = response.headers.get('content-type') || ''; + if ( + response.headers.get('cf-mitigated') === 'challenge' || + (response.status === 403 && !contentType.includes('json')) + ) { + throw new Error( + 'Site is showing a security check, please open it in WebView and complete it.', + ); + } // Missing files (unknown slugs, pages past the end) come back as 403/404. if (response.status === 403 || response.status === 404) return null; if (!response.ok) {