Files
lnreader-plugins/plugins/arabic/sunovels.ts
T
Rajarshee Chatterjee 20c2b75b01 refactor: Overhaul Plugin Architecture & Playground UI (#1846)
* refactor: Move Plugins Out and Consolidate with Multisrc

* feat: Overhaul Playground UI

* feat: Add Filters to New UI

* feat: Persist Selected Plugin

* feat: Add Direct Parse Novel/Chapter Options and Feedback Toasts

[skip ci]

* feat: UX Improvements

* chore: Update Plugin Path in Publish Workflow

* fix: Add Backward Compatibility

* chore: Update Plugin Publishing Script
2025-10-20 22:54:08 +05:30

307 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { CheerioAPI, load as parseHTML } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { Filters, FilterTypes } from '@libs/filterInputs';
import { defaultCover } from '@libs/defaultCover';
class Sunovels implements Plugin.PagePlugin {
id = 'sunovels';
name = 'Sunovels';
version = '1.0.0';
icon = 'src/ar/sunovels/icon.png';
site = 'https://sunovels.com/';
parseNovels(loadedCheerio: CheerioAPI): Plugin.NovelItem[] {
const novels: Plugin.NovelItem[] = [];
const imageUrlList: string[] = [];
loadedCheerio('script').each((idx, ele) => {
const regax = /\/uploads\/[^\s"]+/g;
const scriptText = loadedCheerio(ele).text();
const imageUrlMatched = scriptText.match(regax);
if (imageUrlMatched) {
imageUrlList.push(...imageUrlMatched);
}
});
let counter = 0;
loadedCheerio('.list-item').each((idx, ele) => {
loadedCheerio(ele)
.find('a')
.each((idx, ele) => {
const novelName = loadedCheerio(ele).find('h4').text().trim();
const novelUrl =
loadedCheerio(ele).attr('href')?.trim().replace(/^\/*/, '') || '';
let novelCover = defaultCover;
if (imageUrlList.length > 0) {
novelCover = this.site + imageUrlList[counter].slice(1);
} else {
const imageUrl = loadedCheerio(ele).find('img').attr('src');
novelCover = this.site + imageUrl?.slice(1);
}
const novel = {
name: novelName,
cover: novelCover,
path: novelUrl,
};
counter++;
novels.push(novel);
});
});
return novels;
}
async popularNovels(
page: number,
{ showLatestNovels, filters }: Plugin.PopularNovelsOptions<Filters>,
): Promise<Plugin.NovelItem[]> {
const pageCorrected = page - 1;
let link = `${this.site}library?`;
if (filters) {
if (
Array.isArray(filters.categories.value) &&
filters.categories.value.length > 0
) {
filters.categories.value.forEach((genre: string) => {
link += `&category=${genre}`;
});
}
if (filters.status.value !== '') {
link += `&status=${filters.status.value}`;
}
}
link += `&page=${pageCorrected}`;
const body = await fetchApi(link).then(r => r.text());
const loadedCheerio = parseHTML(body);
return this.parseNovels(loadedCheerio);
}
async parseNovel(
novelUrl: string,
): Promise<Plugin.SourceNovel & { totalPages: number }> {
const result = await fetchApi(new URL(novelUrl, this.site).toString());
const body = await result.text();
const loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel & { totalPages: number } = {
path: novelUrl,
name: loadedCheerio('div.main-head h3').text().trim() || 'Untitled',
author: loadedCheerio('.novel-author').text().trim(),
summary: loadedCheerio('section.info-section div.description p')
.text()
.trim(),
totalPages: 1,
chapters: [],
};
const statusWords = new Set(['مكتمل', 'جديد', 'مستمر']);
const mainGenres = Array.from(loadedCheerio('div.categories li.tag'))
.map(el => loadedCheerio(el).text().trim())
.join(',');
const statusGenre = Array.from(
loadedCheerio('div.header-stats span').eq(3).find('strong'),
)
.map(el => loadedCheerio(el).text().trim())
.filter(text => statusWords.has(text));
novel.genres = `${statusGenre},${mainGenres}`;
const statusText = Array.from(
loadedCheerio('div.header-stats span').eq(3).find('strong'),
)
.map(el => loadedCheerio(el).text().trim())
.filter(text => statusWords.has(text))
.join();
novel.status =
{
'جديد': 'Ongoing',
'مكتمل': 'Completed',
'مستمر': 'Ongoing',
}[statusText] || 'Unknown';
const imageUrl = loadedCheerio('div.img-container figure.cover img').attr(
'src',
);
const imageUrlFull = this.site + imageUrl?.slice(1);
novel.cover = imageUrlFull;
const chapterNumberStr = loadedCheerio('div.header-stats span')
.first()
.text()
.replace(/[^\d]/g, '');
const chapterNumber = parseInt(chapterNumberStr, 10);
const pageNumber = Math.ceil(chapterNumber / 50);
novel.totalPages = pageNumber;
return novel;
}
parseChapters(data: { chapters: ChapterEntry[] }) {
const chapter: Plugin.ChapterItem[] = [];
data.chapters.map((item: ChapterEntry) => {
chapter.push({
name: item.chapterName,
releaseTime: new Date(item.releaseTime).toISOString(),
path: item.chapterUrl,
chapterNumber: item.chapterNumber,
});
});
return chapter;
}
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
const numPage = parseInt(page, 10);
const pageCorrected = numPage - 1;
const pagePath = novelPath;
const firstUrl = this.site + pagePath;
const pageUrl = firstUrl + '?activeTab=chapters&page=' + pageCorrected;
const body = await fetchApi(pageUrl).then(r => r.text());
const loadedCheerio = parseHTML(body);
const dataJson: {
pages_count: string;
chapters: ChapterEntry[];
} = { pages_count: '', chapters: [] };
const chaptersinfo: {
chapterName: string;
chapterUrl: string;
releaseTime: string;
chapterNumber: string | number;
}[] = [];
loadedCheerio('ul.chaptersList a').each((i, el) => {
const chapterName: string = loadedCheerio(el).attr('title') ?? '';
const chapterUrl = loadedCheerio(el)
.attr('href')
?.trim()
.replace(/^\/*/, '');
const dateAttr = loadedCheerio(el)
.find('time.chapter-update')
.attr('datetime');
const date = new Date(dateAttr);
const releaseTime = date.toISOString();
const chapternumber = loadedCheerio(el)
.find('strong.chapter-title')
.text()
.replace(/[^\d٠-٩]/g, '');
const chapterNumber = parseInt(chapternumber, 10);
chaptersinfo.push({
chapterName: chapterName,
chapterUrl: chapterUrl || '',
releaseTime: releaseTime || '',
chapterNumber: chapterNumber || '',
});
});
const pagecount = loadedCheerio('ul.pagination a.active').text();
dataJson.pages_count = pagecount;
dataJson.chapters = chaptersinfo;
const chapters = this.parseChapters(dataJson);
return {
chapters,
};
}
async parseChapter(chapterUrl: string): Promise<string> {
const result = await fetchApi(new URL(chapterUrl, this.site).toString());
const body = await result.text();
const loadedCheerio = parseHTML(body);
let chapterText = '';
loadedCheerio('div.chapter-content').each((idx, ele) => {
loadedCheerio(ele)
.find('p')
.not('.d-none')
.each((idx, textEle) => {
chapterText +=
loadedCheerio(textEle)
.map((_, pEle) => loadedCheerio(pEle).text().trim())
.get()
.join(' ') + ' ';
});
});
chapterText = chapterText.trim();
return chapterText;
}
async searchNovels(
searchTerm: string,
page: number,
): Promise<Plugin.NovelItem[]> {
const searchUrl = `${this.site}search?page=${page}&title=${searchTerm}`;
const result = await fetchApi(searchUrl);
const body = await result.text();
const loadedCheerio = parseHTML(body);
return this.parseNovels(loadedCheerio);
}
filters = {
categories: {
value: [],
label: 'التصنيفات',
options: [
{ label: 'Wuxia', value: 'Wuxia' },
{ label: 'Xianxia', value: 'Xianxia' },
{ label: 'XUANHUAN', value: 'XUANHUAN' },
{ label: 'أصلية', value: 'أصلية' }, // Original
{ label: 'أكشن', value: 'أكشن' }, // Action
{ label: 'إثارة', value: 'إثارة' }, // Thriller
{ label: 'إنتقال الى عالم أخر', value: 'إنتقال+الى+عالم+أخر' }, // Isekai
{ label: 'إيتشي', value: 'إيتشي' }, // Ecchi
{ label: 'الخيال العلمي', value: 'الخيال+العلمي' }, // Science Fiction
{ label: 'بوليسي', value: 'بوليسي' }, // Detective
{ label: 'تاريخي', value: 'تاريخي' }, // Historical
{ label: 'تقمص شخصيات', value: 'تقمص+شخصيات' }, // Roleplaying
{ label: 'جريمة', value: 'جريمة' }, // Crime
{ label: 'جوسى', value: 'جوسى' }, // Josei
{ label: 'حريم', value: 'حريم' }, // Harem
{ label: 'حياة مدرسية', value: 'حياة+مدرسية' }, // School Life
{ label: 'خارقة للطبيعة', value: 'خارقة+للطبيعة' }, // Supernatural
{ label: 'خيالي', value: 'خيالي' }, // Fantasy
{ label: 'دراما', value: 'دراما' }, // Drama
{ label: 'رعب', value: 'رعب' }, // Horror
{ label: 'رومانسي', value: 'رومانسي' }, // Romance
{ label: 'سحر', value: 'سحر' }, // Magic
{ label: 'سينن', value: 'سينن' }, // Seinen
{ label: 'شريحة من الحياة', value: 'شريحة+من+الحياة' }, // Slice of Life
{ label: 'شونين', value: 'شونين' }, // Shounen
{ label: 'غموض', value: 'غموض' }, // Mystery
{ label: 'فنون القتال', value: 'فنون+القتال' }, // Martial Arts
{ label: 'قوى خارقة', value: 'قوى+خارقة' }, // Super Powers
{ label: 'كوميدى', value: 'كوميدى' }, // Comedy
{ label: 'مأساوي', value: 'مأساوي' }, // Tragedy
{ label: 'ما بعد الكارثة', value: 'ما+بعد+الكارثة' }, // Post-Apocalypse
{ label: 'مغامرة', value: 'مغامرة' }, // Adventure
{ label: 'ميكا', value: 'ميكا' }, // Mecha
{ label: 'ناضج', value: 'ناضج' }, // Mature
{ label: 'نفسي', value: 'نفسي' }, // Psychological
{ label: 'فانتازيا', value: 'فانتازيا' }, // Fantasy
{ label: 'رياضة', value: 'رياضة' }, // Sports
{ label: 'ابراج', value: 'ابراج' }, // Astrology
{ label: 'الالهة', value: 'الالهة' }, // Deities
{ label: 'شياطين', value: 'شياطين' }, // Demons
{ label: 'السفر عبر الزمن', value: 'السفر+عبر+الزمن' }, // Time Travel
{ label: 'رواية صينية', value: 'رواية+صينية' }, // Chinese Novel
{ label: 'رواية ويب', value: 'رواية+ويب' }, // Web Novel
{ label: 'لايت نوفل', value: 'لايت+نوفل' }, // Light Novel
{ label: 'كوري', value: 'كوري' }, // Korean
{ label: '+18', value: '%2B18' }, // +18
{ label: 'إيسكاي', value: 'إيسكاي' }, // Isekai
{ label: 'ياباني', value: 'ياباني' }, // Japanese
{ label: 'مؤلفة', value: 'مؤلفة' }, // Authored
],
type: FilterTypes.CheckboxGroup,
},
status: {
value: '',
label: 'الحالة',
options: [
{ label: 'جميع الروايات', value: '' },
{ label: 'مكتمل', value: 'Completed' },
{ label: 'جديد', value: 'New' },
{ label: 'مستمر', value: 'Ongoing' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
}
export default new Sunovels();
type ChapterEntry = {
chapterName: string;
chapterUrl: string;
releaseTime: string;
chapterNumber: string | number;
};