diff --git a/plugins/english/lightnovelworld.ts b/plugins/english/lightnovelworld.ts new file mode 100644 index 000000000..d078084f2 --- /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 '@/types/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.CheckboxGroup, + }, + 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 000000000..c42891003 Binary files /dev/null and b/public/static/src/en/lightnovelworld/icon.png differ