From 60690784ad4a36f7b11a9b8178f0a0e8919cff6e Mon Sep 17 00:00:00 2001 From: root Date: Tue, 21 Apr 2026 03:49:33 +0000 Subject: [PATCH 1/5] fix(novelbuddy): update site URL and fix scraping logic --- plugins/english/novelbuddy.ts | 360 +++++++++++++--------------------- 1 file changed, 141 insertions(+), 219 deletions(-) diff --git a/plugins/english/novelbuddy.ts b/plugins/english/novelbuddy.ts index bd59c9d3c..7cab0e1cf 100644 --- a/plugins/english/novelbuddy.ts +++ b/plugins/english/novelbuddy.ts @@ -1,260 +1,187 @@ -import { CheerioAPI, load as parseHTML } from 'cheerio'; +import { load as parseHTML } from 'cheerio'; import { fetchApi } from '@libs/fetch'; import { Plugin } from '@/types/plugin'; import { Filters, FilterTypes } from '@libs/filterInputs'; class NovelBuddy implements Plugin.PluginBase { id = 'novelbuddy'; - name = 'NovelBuddy.io'; - site = 'https://novelbuddy.io/'; - version = '1.0.4'; + name = 'NovelBuddy'; + site = 'https://novelbuddy.com/'; + version = '2.0.0'; icon = 'src/en/novelbuddy/icon.png'; - parseNovels(loadedCheerio: CheerioAPI) { - const novels: Plugin.NovelItem[] = []; - - loadedCheerio('.book-item').each((idx, ele) => { - const novelName = loadedCheerio(ele).find('.title').text(); - const novelCover = - 'https:' + loadedCheerio(ele).find('img').attr('data-src'); - const novelUrl = loadedCheerio(ele) - .find('.title a') - .attr('href') - ?.substring(1); - - if (!novelUrl) return; - - const novel = { name: novelName, cover: novelCover, path: novelUrl }; - - novels.push(novel); - }); - - return novels; - } + headers = { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Referer': 'https://novelbuddy.com/', + }; async popularNovels( pageNo: number, { filters }: Plugin.PopularNovelsOptions, ): Promise { - // create empty query params const params = new URLSearchParams(); - - // apply all filters - params.append('sort', filters.orderBy.value.toString()); - params.append('status', filters.status.value.toString()); - if (filters.genre.value instanceof Array) { + params.append('sort', filters?.orderBy?.value?.toString() || 'popular'); + params.append('status', filters?.status?.value?.toString() || ''); + if (filters?.genre?.value instanceof Array) { filters.genre.value.forEach(genre => { - params.append('genre[]', genre.toString()); + params.append('genres[]', genre.toString()); }); } - params.append('q', filters.keyword.value.toString()); + if (filters?.keyword?.value) { + params.append('q', filters.keyword.value.toString()); + } params.append('page', pageNo.toString()); - const url = `${this.site}search?${params.toString()}`; + const url = `https://api.novelbuddy.com/titles?${params.toString()}`; + const result = await fetchApi(url, { headers: this.headers }); + const json = await result.json(); - const result = await fetchApi(url); - const body = await result.text(); + if (!json || !json.data || !json.data.items) { + // Fallback to scraping HTML if API fails (likely Cloudflare block) + const htmlUrl = `${this.site}popular?page=${pageNo}`; + const htmlRes = await fetchApi(htmlUrl, { headers: this.headers }); + const htmlBody = await htmlRes.text(); + const $ = parseHTML(htmlBody); + const script = $('#__NEXT_DATA__').html(); + if (script) { + const data = JSON.parse(script); + const items = data.props.pageProps.items || []; + return items.map((item: any) => ({ + name: item.name, + cover: item.cover, + path: item.url.replace(/^\//, ''), + })); + } + return []; + } - const loadedCheerio = parseHTML(body); - return this.parseNovels(loadedCheerio); + return json.data.items.map((item: any) => ({ + name: item.name, + cover: item.cover, + path: item.url.replace(/^\//, ''), + })); } async parseNovel(novelPath: string): Promise { - const response = await fetchApi(this.site + novelPath); + const response = await fetchApi(this.site + novelPath, { + headers: this.headers, + }); const body = await response.text(); - const $ = parseHTML(body); - - const coverSrc = - $('.img-cover img').attr('data-src') || - $('.img-cover img').attr('src') || - ''; - - const normalizeUrl = (url?: string): string => { - if (!url) return ''; - if (url.startsWith('//')) return `https:${url}`; - if (url.startsWith('/')) return `${this.site}${url.replace(/^\//, '')}`; - return url; - }; - - const normalizeText = (text: string): string => - text.replace(/\s+/g, ' ').replace(/\s*,\s*$/, '').trim(); - - const getLinkTextList = (root: ReturnType): string[] => - root - .find('a') - .map((_, el) => normalizeText($(el).text())) - .toArray() - .filter(Boolean); - + + const script = $('#__NEXT_DATA__').html(); + if (!script) throw new Error('Could not find __NEXT_DATA__'); + + const data = JSON.parse(script); + const initialManga = data.props.pageProps.initialManga; + + if (!initialManga) throw new Error('Could not find initialManga data'); + const novel: Plugin.SourceNovel = { path: novelPath, - name: normalizeText($('.name h1').first().text()) || 'Untitled', - cover: normalizeUrl(coverSrc), - summary: normalizeText($('.section-body.summary .content').text()), + name: initialManga.name, + cover: initialManga.cover, + summary: initialManga.summary?.replace(/<[^>]*>?/gm, '') || '', + author: initialManga.authors?.map((a: any) => a.name).join(', ') || '', + artist: initialManga.artists?.map((a: any) => a.name).join(', ') || '', + status: initialManga.status, + genres: initialManga.genres?.map((g: any) => g.name).join(',') || '', chapters: [], }; - - $('.meta.box p').each((_, el) => { - const row = $(el); - const label = normalizeText(row.find('strong').first().text()); - const linkTexts = getLinkTextList(row); - - switch (label) { - case 'Authors :': - novel.author = linkTexts.join(', '); - break; - - case 'Artists :': - case 'Artist :': - novel.artist = linkTexts.join(', '); - break; - - case 'Status :': - novel.status = linkTexts[0] || normalizeText(row.text().replace(label, '')); - break; - - case 'Genres :': - novel.genres = linkTexts.join(','); - break; - } - }); - - const ratingText = normalizeText($('.rating .score').first().text()).replace( - /[^0-9.]/g, - '', - ); - const rating = Number.parseFloat(ratingText); - if (!Number.isNaN(rating)) { - novel.rating = rating; - } - - const scriptText = $('script') - .map((_, el) => $(el).html() || '') - .toArray() - .join('\n'); - - const novelIdMatch = scriptText.match(/bookId\s*=\s*(\d+)\s*;/); - if (!novelIdMatch) { - return novel; + + if (initialManga.ratingStats) { + novel.rating = initialManga.ratingStats.average; } - - const novelId = novelIdMatch[1]; - - const getChapters = async (id: string): Promise => { - const chapterListUrl = `${this.site}api/manga/${id}/chapters?source=detail`; - const chapterResponse = await fetchApi(chapterListUrl); - const chapterHtml = await chapterResponse.text(); - - const $$ = parseHTML(chapterHtml); - const chapters: Plugin.ChapterItem[] = []; - - const months = [ - 'jan', - 'feb', - 'mar', - 'apr', - 'may', - 'jun', - 'jul', - 'aug', - 'sep', - 'oct', - 'nov', - 'dec', - ]; - - const dateRegex = new RegExp( - `(${months.join('|')})\\s+(\\d{1,2}),\\s+(\\d{4})`, - 'i', - ); - - $$('li').each((_, el) => { - const item = $$(el); - - const chapterName = normalizeText(item.find('.chapter-title').text()); - const releaseDateText = normalizeText(item.find('.chapter-update').text()); - const chapterHref = item.find('a').attr('href'); - - if (!chapterHref) return; - - const chapterPath = chapterHref.startsWith('/') - ? chapterHref.slice(1) - : chapterHref; - - const chapterItem: Plugin.ChapterItem = { - name: chapterName || 'Untitled', - path: chapterPath, - }; - - const dateMatch = dateRegex.exec(releaseDateText); - if (dateMatch) { - const year = Number.parseInt(dateMatch[3], 10); - const month = months.indexOf(dateMatch[1].toLowerCase()); - const day = Number.parseInt(dateMatch[2], 10); - - if (month >= 0) { - chapterItem.releaseTime = new Date(year, month, day).toISOString(); - } - } else if (releaseDateText) { - chapterItem.releaseTime = releaseDateText; - } - - const chapterNumberMatch = chapterName.match( - /chapter\s+(\d+(?:\.\d+)?)/i, - ); - if (chapterNumberMatch) { - chapterItem.chapterNumber = Number.parseFloat(chapterNumberMatch[1]); - } - - chapters.push(chapterItem); + + // Fetch full chapter list from API + const chaptersUrl = `https://api.novelbuddy.com/titles/${initialManga.id}/chapters`; + try { + const chaptersResponse = await fetchApi(chaptersUrl, { + headers: this.headers, }); - - return chapters; - }; - - novel.chapters = (await getChapters(novelId)).reverse(); - + const chaptersJson = await chaptersResponse.json(); + + if (chaptersJson?.success && chaptersJson?.data?.chapters) { + novel.chapters = chaptersJson.data.chapters + .map((chapter: any) => ({ + name: chapter.name, + path: chapter.url.replace(/^\//, ''), + releaseTime: chapter.updated_at, + })) + .reverse(); + } else if (initialManga.chapters) { + novel.chapters = initialManga.chapters + .map((chapter: any) => ({ + name: chapter.name, + path: chapter.url.replace(/^\//, ''), + releaseTime: chapter.updatedAt, + })) + .reverse(); + } + } catch (e) { + if (initialManga.chapters) { + novel.chapters = initialManga.chapters + .map((chapter: any) => ({ + name: chapter.name, + path: chapter.url.replace(/^\//, ''), + releaseTime: chapter.updatedAt, + })) + .reverse(); + } + } + return novel; } async parseChapter(chapterPath: string): Promise { - const result = await fetchApi(this.site + chapterPath); + const result = await fetchApi(this.site + chapterPath, { + headers: this.headers, + }); const body = await result.text(); + const $ = parseHTML(body); - const loadedCheerio = parseHTML(body); - - loadedCheerio('#listen-chapter').remove(); - loadedCheerio('#google_translate_element').remove(); + const script = $('#__NEXT_DATA__').html(); + if (!script) throw new Error('Could not find __NEXT_DATA__'); - const chapterText = loadedCheerio('.chapter__content').html() || ''; + const data = JSON.parse(script); + const initialChapter = data.props.pageProps.initialChapter; + if (!initialChapter) throw new Error('Could not find chapter content'); - return chapterText; + return initialChapter.content; } async searchNovels( searchTerm: string, page: number, ): Promise { - const url = `${this.site}search?q=${encodeURIComponent(searchTerm)}&page=${page}`; + const url = `https://api.novelbuddy.com/titles?q=${encodeURIComponent(searchTerm)}&page=${page}`; + const result = await fetchApi(url, { headers: this.headers }); + const json = await result.json(); - const result = await fetchApi(url); - const body = await result.text(); + if (!json || !json.data || !json.data.items) { + return []; + } - const loadedCheerio = parseHTML(body); - return this.parseNovels(loadedCheerio); + return json.data.items.map((item: any) => ({ + name: item.name, + cover: item.cover, + path: item.url.replace(/^\//, ''), + })); } filters = { orderBy: { - value: 'views', + value: 'popular', label: 'Order by', options: [ - { label: 'Views', value: 'views' }, - { label: 'Updated At', value: 'updated_at' }, - { label: 'Created At', value: 'created_at' }, - { label: 'Name', value: 'name' }, - { label: 'Rating', value: 'rating' }, + { label: 'Default', value: '' }, + { label: 'Latest Updated', value: 'latest' }, + { label: 'Most Popular', value: 'popular' }, + { label: 'Highest Rating', value: 'rating' }, + { label: 'Most Viewed', value: 'views' }, + { label: 'Most Chapters', value: 'chapters' }, + { label: 'Alphabetical', value: 'alphabetical' }, ], type: FilterTypes.Picker, }, @@ -264,18 +191,20 @@ class NovelBuddy implements Plugin.PluginBase { type: FilterTypes.TextInput, }, status: { - value: 'all', + value: '', label: 'Status', options: [ - { label: 'All', value: 'all' }, + { label: 'All', value: '' }, { label: 'Ongoing', value: 'ongoing' }, { label: 'Completed', value: 'completed' }, + { label: 'Hiatus', value: 'hiatus' }, + { label: 'Cancelled', value: 'cancelled' }, ], type: FilterTypes.Picker, }, genre: { value: [], - label: 'Genres (OR, not AND)', + label: 'Genres', options: [ { label: 'Action', value: 'action' }, { label: 'Action Adventure', value: 'action-adventure' }, @@ -290,22 +219,19 @@ class NovelBuddy implements Plugin.PluginBase { { label: 'Drama', value: 'drama' }, { label: 'Eastern', value: 'eastern' }, { label: 'Ecchi', value: 'ecchi' }, - { label: 'Fan Fiction', value: 'fan-fiction' }, + { label: 'Fan-Fiction', value: 'fan-fiction' }, { label: 'Fanfiction', value: 'fanfiction' }, - { label: 'Fantas', value: 'fantas' }, { label: 'Fantasy', value: 'fantasy' }, { label: 'Game', value: 'game' }, { label: 'Gender', value: 'gender' }, { label: 'Gender Bender', value: 'gender-bender' }, { label: 'Harem', value: 'harem' }, - { label: 'HaremAction', value: 'haremaction' }, - { label: 'Haremv', value: 'haremv' }, - { label: 'Historica', value: 'historica' }, - { label: 'Historical', value: 'historical' }, { label: 'History', value: 'history' }, { label: 'Horror', value: 'horror' }, { label: 'Isekai', value: 'isekai' }, { label: 'Josei', value: 'josei' }, + { label: 'Light Novel', value: 'light-novel' }, + { label: 'Litrpg', value: 'litrpg' }, { label: 'Lolicon', value: 'lolicon' }, { label: 'Magic', value: 'magic' }, { label: 'Martial', value: 'martial' }, @@ -314,15 +240,11 @@ class NovelBuddy implements Plugin.PluginBase { { label: 'Mecha', value: 'mecha' }, { label: 'Military', value: 'military' }, { label: 'Modern Life', value: 'modern-life' }, + { label: 'Movies', value: 'movies' }, { label: 'Mystery', value: 'mystery' }, - { label: 'Mystery Adventure', value: 'mystery-adventure' }, - { label: 'Psychologic', value: 'psychologic' }, { label: 'Psychological', value: 'psychological' }, { label: 'Reincarnation', value: 'reincarnation' }, { label: 'Romance', value: 'romance' }, - { label: 'Romance Adventure', value: 'romance-adventure' }, - { label: 'Romance Harem', value: 'romance-harem' }, - { label: 'Romancem', value: 'romancem' }, { label: 'School Life', value: 'school-life' }, { label: 'Sci-fi', value: 'sci-fi' }, { label: 'Seinen', value: 'seinen' }, @@ -330,12 +252,12 @@ class NovelBuddy implements Plugin.PluginBase { { 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: 'Slice Of Life', value: 'slice-of-life' }, { label: 'Smut', value: 'smut' }, { label: 'Sports', value: 'sports' }, - { label: 'Superna', value: 'superna' }, { label: 'Supernatural', value: 'supernatural' }, { label: 'System', value: 'system' }, + { label: 'Thriller', value: 'thriller' }, { label: 'Tragedy', value: 'tragedy' }, { label: 'Urban', value: 'urban' }, { label: 'Urban Life', value: 'urban-life' }, From 927a7ff69bad0d9b858cc2ac7ffe91b945d26cac Mon Sep 17 00:00:00 2001 From: root Date: Tue, 21 Apr 2026 07:52:40 +0000 Subject: [PATCH 2/5] feat(novelbuddy): filter webnovel watermark and bump version to 2.0.1 --- plugins/english/novelbuddy.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/plugins/english/novelbuddy.ts b/plugins/english/novelbuddy.ts index 7cab0e1cf..430869723 100644 --- a/plugins/english/novelbuddy.ts +++ b/plugins/english/novelbuddy.ts @@ -148,7 +148,17 @@ class NovelBuddy implements Plugin.PluginBase { const initialChapter = data.props.pageProps.initialChapter; if (!initialChapter) throw new Error('Could not find chapter content'); - return initialChapter.content; + let content = initialChapter.content; + + if (content) { + // Remove Webnovel watermarks/ads + content = content.replace( + /Find authorized novels in Webnovel.*?faster updates, better experience.*?Please click www\.webnovel\.com for visiting\./gi, + '', + ); + } + + return content; } async searchNovels( From 18ca477cda141c52b84a9f828866948067643970 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 21 Apr 2026 16:39:47 +0000 Subject: [PATCH 3/5] feat(novelbuddy): simplify search, fix summary formatting, version 2.0.0 --- package-lock.json | 39 -------- plugins/english/novelbuddy.ts | 172 +++++++--------------------------- 2 files changed, 33 insertions(+), 178 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9066d41d4..42f30918f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2280,9 +2280,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2296,9 +2293,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2312,9 +2306,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2328,9 +2319,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2344,9 +2332,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2360,9 +2345,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2376,9 +2358,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2392,9 +2371,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2408,9 +2384,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2424,9 +2397,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2440,9 +2410,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2456,9 +2423,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2472,9 +2436,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/plugins/english/novelbuddy.ts b/plugins/english/novelbuddy.ts index 430869723..ae4e37fb3 100644 --- a/plugins/english/novelbuddy.ts +++ b/plugins/english/novelbuddy.ts @@ -1,7 +1,6 @@ import { load as parseHTML } from 'cheerio'; import { fetchApi } from '@libs/fetch'; import { Plugin } from '@/types/plugin'; -import { Filters, FilterTypes } from '@libs/filterInputs'; class NovelBuddy implements Plugin.PluginBase { id = 'novelbuddy'; @@ -16,51 +15,39 @@ class NovelBuddy implements Plugin.PluginBase { 'Referer': 'https://novelbuddy.com/', }; - async popularNovels( - pageNo: number, - { filters }: Plugin.PopularNovelsOptions, - ): Promise { - const params = new URLSearchParams(); - params.append('sort', filters?.orderBy?.value?.toString() || 'popular'); - params.append('status', filters?.status?.value?.toString() || ''); - if (filters?.genre?.value instanceof Array) { - filters.genre.value.forEach(genre => { - params.append('genres[]', genre.toString()); - }); - } - if (filters?.keyword?.value) { - params.append('q', filters.keyword.value.toString()); - } - params.append('page', pageNo.toString()); + async popularNovels(pageNo: number): Promise { + const url = `https://api.novelbuddy.com/titles?sort=popular&page=${pageNo}`; - const url = `https://api.novelbuddy.com/titles?${params.toString()}`; - const result = await fetchApi(url, { headers: this.headers }); - const json = await result.json(); + try { + const result = await fetchApi(url, { headers: this.headers }); + const json = await result.json(); - if (!json || !json.data || !json.data.items) { - // Fallback to scraping HTML if API fails (likely Cloudflare block) - const htmlUrl = `${this.site}popular?page=${pageNo}`; - const htmlRes = await fetchApi(htmlUrl, { headers: this.headers }); - const htmlBody = await htmlRes.text(); - const $ = parseHTML(htmlBody); - const script = $('#__NEXT_DATA__').html(); - if (script) { - const data = JSON.parse(script); - const items = data.props.pageProps.items || []; - return items.map((item: any) => ({ + if (json?.data?.items) { + return json.data.items.map((item: any) => ({ name: item.name, cover: item.cover, path: item.url.replace(/^\//, ''), })); } - return []; + } catch (e) { + // Fallback to HTML } - return json.data.items.map((item: any) => ({ - name: item.name, - cover: item.cover, - path: item.url.replace(/^\//, ''), - })); + const htmlUrl = `${this.site}popular?page=${pageNo}`; + const htmlRes = await fetchApi(htmlUrl, { headers: this.headers }); + const htmlBody = await htmlRes.text(); + const $ = parseHTML(htmlBody); + const script = $('#__NEXT_DATA__').html(); + if (script) { + const data = JSON.parse(script); + const items = data.props.pageProps.items || []; + return items.map((item: any) => ({ + name: item.name, + cover: item.cover, + path: item.url.replace(/^\//, ''), + })); + } + return []; } async parseNovel(novelPath: string): Promise { @@ -78,11 +65,19 @@ class NovelBuddy implements Plugin.PluginBase { if (!initialManga) throw new Error('Could not find initialManga data'); + // Fix summary formatting by preserving line breaks before stripping HTML + let formattedSummary = initialManga.summary || ''; + formattedSummary = formattedSummary + .replace(//gi, '\n') // Replace
or
with newline + .replace(/<\/p>/gi, '\n\n') // Replace

with double newline for paragraphs + .replace(/<[^>]*>?/gm, '') // Strip all remaining HTML tags + .trim(); // Remove extra whitespace at start/end + const novel: Plugin.SourceNovel = { path: novelPath, name: initialManga.name, cover: initialManga.cover, - summary: initialManga.summary?.replace(/<[^>]*>?/gm, '') || '', + summary: formattedSummary, author: initialManga.authors?.map((a: any) => a.name).join(', ') || '', artist: initialManga.artists?.map((a: any) => a.name).join(', ') || '', status: initialManga.status, @@ -179,107 +174,6 @@ class NovelBuddy implements Plugin.PluginBase { path: item.url.replace(/^\//, ''), })); } - - filters = { - orderBy: { - value: 'popular', - label: 'Order by', - options: [ - { label: 'Default', value: '' }, - { label: 'Latest Updated', value: 'latest' }, - { label: 'Most Popular', value: 'popular' }, - { label: 'Highest Rating', value: 'rating' }, - { label: 'Most Viewed', value: 'views' }, - { label: 'Most Chapters', value: 'chapters' }, - { label: 'Alphabetical', value: 'alphabetical' }, - ], - type: FilterTypes.Picker, - }, - keyword: { - value: '', - label: 'Keywords', - type: FilterTypes.TextInput, - }, - status: { - value: '', - label: 'Status', - options: [ - { label: 'All', value: '' }, - { label: 'Ongoing', value: 'ongoing' }, - { label: 'Completed', value: 'completed' }, - { label: 'Hiatus', value: 'hiatus' }, - { label: 'Cancelled', value: 'cancelled' }, - ], - type: FilterTypes.Picker, - }, - genre: { - value: [], - label: 'Genres', - options: [ - { label: 'Action', value: 'action' }, - { label: 'Action Adventure', value: 'action-adventure' }, - { label: 'Adult', value: 'adult' }, - { label: 'Adventcure', value: 'adventcure' }, - { label: 'Adventure', value: 'adventure' }, - { label: 'Adventurer', value: 'adventurer' }, - { label: 'Bender', value: 'bender' }, - { label: 'Chinese', value: 'chinese' }, - { label: 'Comedy', value: 'comedy' }, - { label: 'Cultivation', value: 'cultivation' }, - { label: 'Drama', value: 'drama' }, - { label: 'Eastern', value: 'eastern' }, - { label: 'Ecchi', value: 'ecchi' }, - { label: 'Fan-Fiction', value: 'fan-fiction' }, - { label: 'Fanfiction', value: 'fanfiction' }, - { label: 'Fantasy', value: 'fantasy' }, - { label: 'Game', value: 'game' }, - { label: 'Gender', value: 'gender' }, - { label: 'Gender Bender', value: 'gender-bender' }, - { label: 'Harem', value: 'harem' }, - { label: 'History', value: 'history' }, - { label: 'Horror', value: 'horror' }, - { label: 'Isekai', value: 'isekai' }, - { label: 'Josei', value: 'josei' }, - { label: 'Light Novel', value: 'light-novel' }, - { label: 'Litrpg', value: 'litrpg' }, - { label: 'Lolicon', value: 'lolicon' }, - { label: 'Magic', value: 'magic' }, - { label: 'Martial', value: 'martial' }, - { 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: 'Movies', value: 'movies' }, - { label: 'Mystery', value: 'mystery' }, - { 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: 'Smut', value: 'smut' }, - { 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: 'Urban Life', value: 'urban-life' }, - { label: 'Wuxia', value: 'wuxia' }, - { label: 'Xianxia', value: 'xianxia' }, - { label: 'Xuanhuan', value: 'xuanhuan' }, - { label: 'Yaoi', value: 'yaoi' }, - { label: 'Yuri', value: 'yuri' }, - ], - type: FilterTypes.CheckboxGroup, - }, - } satisfies Filters; } export default new NovelBuddy(); From 69eb7a3b4130dbd6429da10668928d092c5058ee Mon Sep 17 00:00:00 2001 From: root Date: Tue, 21 Apr 2026 22:45:19 +0000 Subject: [PATCH 4/5] chore: restore package-lock.json libc platform constraints Reverts lockfile pruning from commit 18ca477 to recover deleted glibc and musl entries for cross-platform compatibility. --- package-lock.json | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/package-lock.json b/package-lock.json index 42f30918f..9066d41d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2280,6 +2280,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2293,6 +2296,9 @@ "cpu": [ "arm" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2306,6 +2312,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2319,6 +2328,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2332,6 +2344,9 @@ "cpu": [ "loong64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2345,6 +2360,9 @@ "cpu": [ "loong64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2358,6 +2376,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2371,6 +2392,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2384,6 +2408,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2397,6 +2424,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2410,6 +2440,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2423,6 +2456,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2436,6 +2472,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ From 63f27780f181979a5e83d83b36d0f091b6658c4d Mon Sep 17 00:00:00 2001 From: 7ui77 <99854073+7ui77@users.noreply.github.com> Date: Wed, 22 Apr 2026 06:19:08 +0700 Subject: [PATCH 5/5] update imports --- plugins/english/novelbuddy.ts | 187 +++++++++++++++++++++++++++++++++- 1 file changed, 186 insertions(+), 1 deletion(-) diff --git a/plugins/english/novelbuddy.ts b/plugins/english/novelbuddy.ts index ae4e37fb3..258a63885 100644 --- a/plugins/english/novelbuddy.ts +++ b/plugins/english/novelbuddy.ts @@ -6,7 +6,7 @@ class NovelBuddy implements Plugin.PluginBase { id = 'novelbuddy'; name = 'NovelBuddy'; site = 'https://novelbuddy.com/'; - version = '2.0.0'; + version = '2.0.0'; // Bumped version icon = 'src/en/novelbuddy/icon.png'; headers = { @@ -151,6 +151,191 @@ class NovelBuddy implements Plugin.PluginBase { /Find authorized novels in Webnovel.*?faster updates, better experience.*?Please click www\.webnovel\.com for visiting\./gi, '', ); + + // Remove obfuscated freewebnovel watermarks (e.g., free𝑤𝑒𝑏novel.com) + content = content.replace(/free.*?novel\.com/gi, ''); + } + + return content; + } + + async searchNovels( + searchTerm: string, + page: number, + ): Promise { + const url = `https://api.novelbuddy.com/titles?q=${encodeURIComponent(searchTerm)}&page=${page}`; + const result = await fetchApi(url, { headers: this.headers }); + const json = await result.json(); + + if (!json || !json.data || !json.data.items) { + return []; + } + + return json.data.items.map((item: any) => ({ + name: item.name, + cover: item.cover, + path: item.url.replace(/^\//, ''), + })); + } +} + +export default new NovelBuddy(); +import { load as parseHTML } from 'cheerio'; +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; + +class NovelBuddy implements Plugin.PluginBase { + id = 'novelbuddy'; + name = 'NovelBuddy'; + site = 'https://novelbuddy.com/'; + version = '2.0.1'; // Bumped version + icon = 'src/en/novelbuddy/icon.png'; + + headers = { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Referer': 'https://novelbuddy.com/', + }; + + async popularNovels(pageNo: number): Promise { + const url = `https://api.novelbuddy.com/titles?sort=popular&page=${pageNo}`; + + try { + const result = await fetchApi(url, { headers: this.headers }); + const json = await result.json(); + + if (json?.data?.items) { + return json.data.items.map((item: any) => ({ + name: item.name, + cover: item.cover, + path: item.url.replace(/^\//, ''), + })); + } + } catch (e) { + // Fallback to HTML + } + + const htmlUrl = `${this.site}popular?page=${pageNo}`; + const htmlRes = await fetchApi(htmlUrl, { headers: this.headers }); + const htmlBody = await htmlRes.text(); + const $ = parseHTML(htmlBody); + const script = $('#__NEXT_DATA__').html(); + if (script) { + const data = JSON.parse(script); + const items = data.props.pageProps.items || []; + return items.map((item: any) => ({ + name: item.name, + cover: item.cover, + path: item.url.replace(/^\//, ''), + })); + } + return []; + } + + async parseNovel(novelPath: string): Promise { + const response = await fetchApi(this.site + novelPath, { + headers: this.headers, + }); + const body = await response.text(); + const $ = parseHTML(body); + + const script = $('#__NEXT_DATA__').html(); + if (!script) throw new Error('Could not find __NEXT_DATA__'); + + const data = JSON.parse(script); + const initialManga = data.props.pageProps.initialManga; + + if (!initialManga) throw new Error('Could not find initialManga data'); + + // Fix summary formatting by preserving line breaks before stripping HTML + let formattedSummary = initialManga.summary || ''; + formattedSummary = formattedSummary + .replace(//gi, '\n') // Replace
or
with newline + .replace(/<\/p>/gi, '\n\n') // Replace

with double newline for paragraphs + .replace(/<[^>]*>?/gm, '') // Strip all remaining HTML tags + .trim(); // Remove extra whitespace at start/end + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: initialManga.name, + cover: initialManga.cover, + summary: formattedSummary, + author: initialManga.authors?.map((a: any) => a.name).join(', ') || '', + artist: initialManga.artists?.map((a: any) => a.name).join(', ') || '', + status: initialManga.status, + genres: initialManga.genres?.map((g: any) => g.name).join(',') || '', + chapters: [], + }; + + if (initialManga.ratingStats) { + novel.rating = initialManga.ratingStats.average; + } + + // Fetch full chapter list from API + const chaptersUrl = `https://api.novelbuddy.com/titles/${initialManga.id}/chapters`; + try { + const chaptersResponse = await fetchApi(chaptersUrl, { + headers: this.headers, + }); + const chaptersJson = await chaptersResponse.json(); + + if (chaptersJson?.success && chaptersJson?.data?.chapters) { + novel.chapters = chaptersJson.data.chapters + .map((chapter: any) => ({ + name: chapter.name, + path: chapter.url.replace(/^\//, ''), + releaseTime: chapter.updated_at, + })) + .reverse(); + } else if (initialManga.chapters) { + novel.chapters = initialManga.chapters + .map((chapter: any) => ({ + name: chapter.name, + path: chapter.url.replace(/^\//, ''), + releaseTime: chapter.updatedAt, + })) + .reverse(); + } + } catch (e) { + if (initialManga.chapters) { + novel.chapters = initialManga.chapters + .map((chapter: any) => ({ + name: chapter.name, + path: chapter.url.replace(/^\//, ''), + releaseTime: chapter.updatedAt, + })) + .reverse(); + } + } + + return novel; + } + + async parseChapter(chapterPath: string): Promise { + const result = await fetchApi(this.site + chapterPath, { + headers: this.headers, + }); + const body = await result.text(); + const $ = parseHTML(body); + + const script = $('#__NEXT_DATA__').html(); + if (!script) throw new Error('Could not find __NEXT_DATA__'); + + const data = JSON.parse(script); + const initialChapter = data.props.pageProps.initialChapter; + if (!initialChapter) throw new Error('Could not find chapter content'); + + let content = initialChapter.content; + + if (content) { + // Remove Webnovel watermarks/ads + content = content.replace( + /Find authorized novels in Webnovel.*?faster updates, better experience.*?Please click www\.webnovel\.com for visiting\./gi, + '', + ); + + // Remove obfuscated freewebnovel watermarks (e.g., free𝑤𝑒𝑏novel.com) + content = content.replace(/free.*?novel\.com/gi, ''); } return content;