Files
lnreader-plugins/plugins/english/lightnovelworld.ts
T
2026-08-08 15:28:36 +05:30

351 lines
12 KiB
TypeScript

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<string, string> = {
'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.1";
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: '100-500' },
{ label: '500-1000', value: '500-1000' },
{ label: '>1000', value: '>1000' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
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<Plugin.ChapterItem[]> {
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<Plugin.SourceNovel> {
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<string> {
const html = await fetchText(`${this.site}${chapterPath}`);
const $ = loadCheerio(html);
const container = $('#chapterText');
if (!container.length) {
return '<p>No content found.</p>';
}
container.find('script, style, ins, iframe, .ads, .ad-container, .watermark').remove();
container.find('[style]').removeAttr('style');
const content = container.html()?.trim() || '';
return content || '<p>No content found.</p>';
}
async searchNovels(
searchTerm: string,
pageNo: number
): Promise<Plugin.NovelItem[]> {
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<string>();
$('.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();