From 69aa8be46bc07ede8e3c99c4a67072d72ebf6e7c Mon Sep 17 00:00:00 2001 From: 52191314 Date: Sat, 25 Jul 2026 09:01:44 +0700 Subject: [PATCH 1/4] feat(english): add LightNovelWorld plugin --- plugins/english/lightnovelworld.ts | 350 ++++++++++++++++++ public/static/src/en/lightnovelworld/icon.png | Bin 0 -> 3354 bytes 2 files changed, 350 insertions(+) create mode 100644 plugins/english/lightnovelworld.ts create mode 100644 public/static/src/en/lightnovelworld/icon.png diff --git a/plugins/english/lightnovelworld.ts b/plugins/english/lightnovelworld.ts new file mode 100644 index 000000000..07c9bd770 --- /dev/null +++ b/plugins/english/lightnovelworld.ts @@ -0,0 +1,350 @@ +import { fetchText, fetchApi } from '@libs/fetch'; +import { load as loadCheerio } from 'cheerio'; +import { Plugin } from '@typings/plugin'; +import { NovelStatus } from '@libs/novelStatus'; +import { FilterTypes, Filters } from '@libs/filterInputs'; + +const STATUS_MAP: Record = { + 'ongoing': NovelStatus.Ongoing, + 'completed': NovelStatus.Completed, + 'complete': NovelStatus.Completed, + 'hiatus': NovelStatus.OnHiatus, + 'paused': NovelStatus.OnHiatus, +}; + +type SearchResult = { + title?: string; + cover_path?: string; + slug?: string; +}; + +export class LightNovelWorldPlugin implements Plugin.PluginBase { + id = "lightnovelworld"; + name = "LightNovelWorld"; + icon = "src/en/lightnovelworld/icon.png"; + site = "https://lightnovelworld.org/"; + version = "1.0.0"; + + filters = { + sort: { + label: 'Sort By', + value: 'rank', + options: [ + { label: 'Rank', value: 'rank' }, + { label: 'Popular', value: 'popular' }, + { label: 'Rating', value: 'rating' }, + { label: 'Bookmarks', value: 'bookmarks' }, + { label: 'Views', value: 'views' }, + { label: 'Chapters', value: 'chapters' }, + { label: 'New', value: 'new' }, + ], + type: FilterTypes.Picker, + }, + order: { + label: 'Order', + value: 'desc', + options: [ + { label: 'Descending', value: 'desc' }, + { label: 'Ascending', value: 'asc' }, + ], + type: FilterTypes.Picker, + }, + status: { + label: 'Status', + value: [], + options: [ + { label: 'Ongoing', value: 'ongoing' }, + { label: 'Completed', value: 'completed' }, + { label: 'Hiatus', value: 'hiatus' }, + ], + type: FilterTypes.ExcludableCheckboxGroup, + }, + genre_logic: { + label: 'Genre Logic', + value: 'AND', + options: [ + { label: 'AND', value: 'AND' }, + { label: 'OR', value: 'OR' }, + ], + type: FilterTypes.Picker, + }, + genres: { + label: 'Genres', + value: { include: [], exclude: [] }, + options: [ + { label: 'Action', value: 'Action' }, + { label: 'Adult', value: 'Adult' }, + { label: 'Adventure', value: 'Adventure' }, + { label: 'Comedy', value: 'Comedy' }, + { label: 'Drama', value: 'Drama' }, + { label: 'Eastern', value: 'Eastern' }, + { label: 'Ecchi', value: 'Ecchi' }, + { label: 'Fan-Fiction', value: 'Fan-Fiction' }, + { label: 'Fantasy', value: 'Fantasy' }, + { label: 'Game', value: 'Game' }, + { label: 'Gender-Bender', value: 'Gender-Bender' }, + { label: 'Harem', value: 'Harem' }, + { label: 'Historical', value: 'Historical' }, + { label: 'Horror', value: 'Horror' }, + { label: 'Isekai', value: 'Isekai' }, + { label: 'Josei', value: 'Josei' }, + { label: 'LGBT+', value: 'LGBT+' }, + { 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: 'Mystery', value: 'Mystery' }, + { label: 'Psychological', value: 'Psychological' }, + { 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: 'Shounen', value: 'Shounen' }, + { label: 'Slice of Life', value: 'Slice-of-Life' }, + { label: 'Sports', value: 'Sports' }, + { label: 'Supernatural', value: 'Supernatural' }, + { label: 'Thriller', value: 'Thriller' }, + { label: 'Tragedy', value: 'Tragedy' }, + { label: 'Wuxia', value: 'Wuxia' }, + { label: 'Xianxia', value: 'Xianxia' }, + { label: 'Xuanhuan', value: 'Xuanhuan' }, + { label: 'Yaoi', value: 'Yaoi' }, + { label: 'Yuri', value: 'Yuri' }, + ], + type: FilterTypes.ExcludableCheckboxGroup, + }, + tags_include: { + label: 'Include Tags', + value: '', + type: FilterTypes.TextInput, + }, + tags_exclude: { + label: 'Exclude Tags', + value: '', + type: FilterTypes.TextInput, + }, + chapter_range: { + label: 'Chapter Count', + value: 'all', + options: [ + { label: 'All', value: 'all' }, + { label: '<50', value: '<50' }, + { label: '50-100', value: '50-100' }, + { label: '100-500', value: '500-1000' }, + { label: '500-1000', value: '500-1000' }, + { label: '>1000', value: '>1000' }, + ], + type: FilterTypes.Picker, + }, + } satisfies Filters; + + async popularNovels( + pageNo: number, + { + showLatestNovels, + filters, + }: Plugin.PopularNovelsOptions, + ): Promise { + const params = new URLSearchParams(); + + for (const genre of filters.genres.value.include ?? []) { + params.append('genres_include', genre); + } + for (const genre of filters.genres.value.exclude ?? []) { + params.append('genres_exclude', genre); + } + + if (filters.genre_logic.value !== 'AND') { + params.set('genre_logic', filters.genre_logic.value); + } + + if (filters.tags_include.value) { + params.set('tags_include', filters.tags_include.value); + } + if (filters.tags_exclude.value) { + params.set('tags_exclude', filters.tags_exclude.value); + } + + if (filters.chapter_range.value !== 'all') { + params.set('chapter_range', filters.chapter_range.value); + } + + for (const s of filters.status.value) { + params.append('status', s); + } + + if (showLatestNovels) { + params.set('sort', 'new'); + } else if (filters.sort.value !== 'rank') { + params.set('sort', filters.sort.value); + } + + if (filters.order.value !== 'asc') { + params.set('order', filters.order.value); + } + + if (pageNo > 1) { + params.set('page', pageNo.toString()); + } + + const url = `${this.site}advanced-search/?${params.toString()}`; + const html = await fetchText(url); + return this.parseNovelList(html); + } + + async fetchAllChapters(slug: string): Promise { + const LIMIT = 500; + const apiBase = `${this.site}api/novel/${slug}/chapters/?limit=${LIMIT}`; + + const firstRes = await fetchApi(`${apiBase}&offset=0`); + const firstJson = (await firstRes.json()) as { + chapters: { number: number; title: string }[]; + total_chapters: number; + }; + + const total = firstJson.total_chapters; + const offsets: number[] = []; + for (let offset = LIMIT; offset < total; offset += LIMIT) { + offsets.push(offset); + } + + const remainingResults = await Promise.all( + offsets.map(async (offset) => { + try { + const res = await fetchApi(`${apiBase}&offset=${offset}`); + const json = (await res.json()) as { chapters: { number: number; title: string }[] }; + return json.chapters || []; + } catch { + return []; + } + }) + ); + + const rawChapters = [...(firstJson.chapters || []), ...remainingResults.flat()]; + + return rawChapters + .map((ch) => ({ + name: (ch.title || `Chapter ${ch.number}`).trim(), + path: `novel/${slug}/chapter/${ch.number}/`, + chapterNumber: ch.number, + })) + .sort((a, b) => (a.chapterNumber || 0) - (b.chapterNumber || 0)); + } + + async parseNovel(novelPath: string): Promise { + const html = await fetchText(`${this.site}${novelPath}`); + const $ = loadCheerio(html); + + const title = $('.novel-title').text().trim() || 'Untitled Novel'; + + const $cover = $('.novel-cover'); + const rawCover = $cover.attr('src') || ''; + const cover = rawCover ? `${this.site}${rawCover.replace(/^\//, '')}` : ''; + + const summary = $('.summary-content').text().trim(); + const author = $('.author-link').text().trim() || 'Unknown Author'; + + const rawStatus = $('.status-badge').text().trim().toLowerCase(); + const status = STATUS_MAP[rawStatus] || NovelStatus.Unknown; + + const genres = $('.genre-tag').map((_, el) => $(el).text().trim()).get().join(', '); + + const slug = novelPath.replace(/^\/?novel\/|\/$/g, ''); + let chapters: Plugin.ChapterItem[] = []; + + if (slug) { + try { + chapters = await this.fetchAllChapters(slug); + } catch { + chapters = []; + } + } + + return { + path: novelPath, + name: title, + cover, + summary, + author, + status, + genres, + chapters, + }; + } + + async parseChapter(chapterPath: string): Promise { + const html = await fetchText(`${this.site}${chapterPath}`); + const $ = loadCheerio(html); + + const container = $('#chapterText'); + if (!container.length) { + return '

No content found.

'; + } + + container.find('script, style, ins, iframe, .ads, .ad-container, .watermark').remove(); + container.find('[style]').removeAttr('style'); + + const content = container.html()?.trim() || ''; + return content || '

No content found.

'; + } + + async searchNovels( + searchTerm: string, + pageNo: number + ): Promise { + if (!searchTerm?.trim() || pageNo > 1) return []; + const url = `${this.site}api/search/?q=${encodeURIComponent(searchTerm)}&search_type=title`; + try { + const res = await fetchApi(url); + const json = (await res.json()) as { novels?: SearchResult[] }; + if (!json?.novels || !Array.isArray(json.novels)) { + return []; + } + return json.novels + .filter((item): item is SearchResult & { title: string; slug: string } => !!item.slug && !!item.title) + .map((item) => { + const rawCover = item.cover_path || ''; + const cover = rawCover ? `${this.site}${rawCover.replace(/^\//, '')}` : ''; + return { + name: item.title, + cover, + path: `novel/${item.slug}/`, + }; + }); + } catch { + return []; + } + } + + parseNovelList(html: string): Plugin.NovelItem[] { + const $ = loadCheerio(html); + const novels: Plugin.NovelItem[] = []; + const seen = new Set(); + + $('.card-cover-link').each((_, el) => { + const item = $(el); + const rawPath = item.attr('href') || ''; + if (!rawPath) return; + + const rawCover = item.find('img'); + const name = rawCover.attr('alt')?.trim() || ''; + const imgEl = rawCover.attr('src') || ''; + + if (name && rawPath) { + let cleanPath = rawPath.replace(/^\//, ''); + if (!cleanPath.endsWith('/')) cleanPath += '/'; + if (seen.has(cleanPath)) return; + seen.add(cleanPath); + + const cover = imgEl ? `${this.site}${imgEl.replace(/^\//, '')}` : ''; + novels.push({ name, cover, path: cleanPath }); + } + }); + + return novels; + } +} + +export default new LightNovelWorldPlugin(); diff --git a/public/static/src/en/lightnovelworld/icon.png b/public/static/src/en/lightnovelworld/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c42891003bbc9571fce7e4d6c5af6b31bb192c9a GIT binary patch literal 3354 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 literal 0 HcmV?d00001 From 0bca71ec21dbc5dcc81682551c19fb47c19a038d Mon Sep 17 00:00:00 2001 From: Ahjie521 Date: Sat, 25 Jul 2026 13:39:23 +0700 Subject: [PATCH 2/4] Update plugins/english/lightnovelworld.ts Co-authored-by: K1ngfish3r <26593485+K1ngfish3r@users.noreply.github.com> --- plugins/english/lightnovelworld.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/english/lightnovelworld.ts b/plugins/english/lightnovelworld.ts index 07c9bd770..b9d355221 100644 --- a/plugins/english/lightnovelworld.ts +++ b/plugins/english/lightnovelworld.ts @@ -4,7 +4,7 @@ import { Plugin } from '@typings/plugin'; import { NovelStatus } from '@libs/novelStatus'; import { FilterTypes, Filters } from '@libs/filterInputs'; -const STATUS_MAP: Record = { +const STATUS_MAP: Record = { 'ongoing': NovelStatus.Ongoing, 'completed': NovelStatus.Completed, 'complete': NovelStatus.Completed, From 22e12c7ad53831108fc3e3751e5151e9d9338315 Mon Sep 17 00:00:00 2001 From: Ahjie521 Date: Sat, 25 Jul 2026 13:40:07 +0700 Subject: [PATCH 3/4] Update plugins/english/lightnovelworld.ts Co-authored-by: K1ngfish3r <26593485+K1ngfish3r@users.noreply.github.com> --- plugins/english/lightnovelworld.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/english/lightnovelworld.ts b/plugins/english/lightnovelworld.ts index b9d355221..aec4afd0e 100644 --- a/plugins/english/lightnovelworld.ts +++ b/plugins/english/lightnovelworld.ts @@ -1,6 +1,6 @@ import { fetchText, fetchApi } from '@libs/fetch'; import { load as loadCheerio } from 'cheerio'; -import { Plugin } from '@typings/plugin'; +import { Plugin } from '@/types/plugin'; import { NovelStatus } from '@libs/novelStatus'; import { FilterTypes, Filters } from '@libs/filterInputs'; From abcc15425148b4171672d18298ef4399f24d4106 Mon Sep 17 00:00:00 2001 From: Ahjie521 Date: Sat, 25 Jul 2026 13:40:49 +0700 Subject: [PATCH 4/4] Update plugins/english/lightnovelworld.ts Co-authored-by: K1ngfish3r <26593485+K1ngfish3r@users.noreply.github.com> --- plugins/english/lightnovelworld.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/english/lightnovelworld.ts b/plugins/english/lightnovelworld.ts index aec4afd0e..d078084f2 100644 --- a/plugins/english/lightnovelworld.ts +++ b/plugins/english/lightnovelworld.ts @@ -57,7 +57,7 @@ export class LightNovelWorldPlugin implements Plugin.PluginBase { { label: 'Completed', value: 'completed' }, { label: 'Hiatus', value: 'hiatus' }, ], - type: FilterTypes.ExcludableCheckboxGroup, + type: FilterTypes.CheckboxGroup, }, genre_logic: { label: 'Genre Logic',