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
This commit is contained in:
committed by
GitHub
parent
252beff0bf
commit
20c2b75b01
@@ -0,0 +1,424 @@
|
||||
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 dilartube implements Plugin.PluginBase {
|
||||
id = 'dilartube';
|
||||
name = 'dilar tube';
|
||||
version = '1.0.1';
|
||||
icon = 'src/ar/dilartube/icon.png';
|
||||
site = 'https://golden.rest/';
|
||||
|
||||
parseNovels(data: ApiResponse): Plugin.NovelItem[] {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
const seenTitles = new Set<string>();
|
||||
if (data.data && data.data.length > 0) {
|
||||
data.data
|
||||
.filter(dataItem => dataItem.is_novel)
|
||||
.forEach(dataItem => {
|
||||
const manga = dataItem;
|
||||
if (!seenTitles.has(dataItem.title)) {
|
||||
seenTitles.add(manga.title);
|
||||
novels.push({
|
||||
name: dataItem.title || 'novel',
|
||||
path: `mangas/${manga.id}`,
|
||||
cover: manga.cover
|
||||
? `${this.site}uploads/manga/cover/${manga.id}/${manga.cover}`
|
||||
: defaultCover,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
if (data.releases && data.releases.length > 0) {
|
||||
data.releases
|
||||
.filter(release => release.manga.is_novel)
|
||||
.forEach(release => {
|
||||
const manga = release.manga;
|
||||
if (!seenTitles.has(manga.title)) {
|
||||
seenTitles.add(manga.title);
|
||||
novels.push({
|
||||
name: manga.title || 'novel',
|
||||
path: `mangas/${manga.id}`,
|
||||
cover: manga.cover
|
||||
? `${this.site}uploads/manga/cover/${manga.id}/${manga.cover}`
|
||||
: defaultCover,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ showLatestNovels, filters }: Plugin.PopularNovelsOptions<Filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}api/releases?page=${page}`;
|
||||
if (showLatestNovels) {
|
||||
link = `${this.site}api/releases?page=${page}`;
|
||||
}
|
||||
// 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=${page}`;
|
||||
// const body = await fetchApi(link).then(r => r.text());
|
||||
const response = await fetchApi(link).then(r => r.json());
|
||||
return this.parseNovels(response);
|
||||
}
|
||||
|
||||
async parseNovel(novelUrl: string): Promise<Plugin.SourceNovel> {
|
||||
const chapterItems: Plugin.ChapterItem[] = [];
|
||||
const fullUrl = this.site + 'api/' + novelUrl;
|
||||
const chapterUrl = this.site + 'api/' + novelUrl + '/releases';
|
||||
const manga = await fetchApi(fullUrl).then(r => r.json());
|
||||
const chapters = await fetchApi(chapterUrl).then(r => r.json());
|
||||
const mangaData = manga.mangaData;
|
||||
const chapterData = chapters.releases;
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelUrl,
|
||||
name: mangaData.arabic_title || 'Untitled',
|
||||
author:
|
||||
(mangaData.authors.length > 0 ? mangaData.authors[0].name : '') ||
|
||||
'Unknown',
|
||||
summary: mangaData.summary || '',
|
||||
cover: `${this.site}uploads/manga/cover/${mangaData.id}/${mangaData.cover}`,
|
||||
chapters: [],
|
||||
};
|
||||
const translationStatusId: string = mangaData.translation_status;
|
||||
const translationText =
|
||||
{
|
||||
'1': 'مستمره',
|
||||
'0': 'منتهية',
|
||||
'2': 'متوقفة',
|
||||
'3': 'غير مترجمه',
|
||||
}[translationStatusId] || 'غير معروف';
|
||||
const statusWords = new Set(['مكتمل', 'جديد', 'مستمر']);
|
||||
const mainGenres = mangaData.categories
|
||||
.map((category: { name: any }) => category.name)
|
||||
.join(',');
|
||||
novel.genres = `${translationText},${mainGenres}`;
|
||||
|
||||
const statusId: string = mangaData.story_status;
|
||||
const statusText =
|
||||
{
|
||||
'2': 'Ongoing',
|
||||
'3': 'Completed',
|
||||
}[statusId] || 'Unknown';
|
||||
novel.status = statusText;
|
||||
chapterData.map((item: ChapterRelease) => {
|
||||
chapterItems.push({
|
||||
name: item.title,
|
||||
releaseTime: new Date(item.created_at).toISOString(),
|
||||
path: `${novelUrl}/${mangaData.title.replace(' ', '-')}/${item.chapter}`,
|
||||
chapterNumber: item.chapter,
|
||||
});
|
||||
});
|
||||
novel.chapters = chapterItems.reverse();
|
||||
return novel;
|
||||
}
|
||||
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);
|
||||
|
||||
const jsonData = loadedCheerio('script.js-react-on-rails-component').html();
|
||||
const parsedData = JSON.parse(jsonData as string);
|
||||
|
||||
const chapterText = parsedData.readerDataAction.readerData.release.content;
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const formData = new FormData();
|
||||
formData.append('query', searchTerm);
|
||||
formData.append('includes', '["Manga","Team","Member"]');
|
||||
const response = await fetchApi('https://dilar.tube/api/quick_search', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
}).then(r => r.json());
|
||||
const data: ApiResponse = response[0];
|
||||
return this.parseNovels(data);
|
||||
}
|
||||
|
||||
// filters = {
|
||||
// types: {
|
||||
// value: [],
|
||||
// label: 'الأنواع',
|
||||
// options: [
|
||||
// { label: 'يابانية', value: '1' }, // Japanese
|
||||
// { label: 'كورية', value: '2' }, // Korean
|
||||
// { label: 'صينية', value: '3' }, // Chinese
|
||||
// { label: 'عربية', value: '4' }, // Arabic
|
||||
// { label: 'كوميك', value: '5' }, // Comic
|
||||
// { label: 'هواة', value: '6' }, // Amateur
|
||||
// { label: 'إندونيسية', value: '7' }, // Indonesian
|
||||
// { label: 'روسية', value: '8' }, // Russian
|
||||
// ],
|
||||
// type: FilterTypes.CheckboxGroup,
|
||||
// },
|
||||
|
||||
// status: {
|
||||
// value: '',
|
||||
// label: 'الحالة',
|
||||
// options: [
|
||||
// { label: 'مستمرة', value: '2' }, // Ongoing
|
||||
// { label: 'منتهية', value: '3' }, // Completed
|
||||
// ],
|
||||
// type: FilterTypes.CheckboxGroup,
|
||||
// },
|
||||
|
||||
// translation: {
|
||||
// value: [],
|
||||
// label: 'الترجمة',
|
||||
// options: [
|
||||
// { label: 'منتهية', value: '0' }, // Completed
|
||||
// { label: 'مستمرة', value: '1' }, // Ongoing
|
||||
// { label: 'متوقفة', value: '2' }, // Paused
|
||||
// { label: 'غير مترجمة', value: '3' }, // Not Translated
|
||||
// ],
|
||||
// type: FilterTypes.CheckboxGroup,
|
||||
// },
|
||||
// } satisfies Filters;
|
||||
}
|
||||
|
||||
export default new dilartube();
|
||||
|
||||
type Category = {
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
manga_id: number;
|
||||
};
|
||||
|
||||
type Type = {
|
||||
id: number;
|
||||
name: string;
|
||||
reading_direction: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type Manga = {
|
||||
id: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
is_novel: boolean;
|
||||
is_oneshot: boolean;
|
||||
genre_id: number;
|
||||
cover: string;
|
||||
cover_pos: number;
|
||||
rectangle_cover_pos: number;
|
||||
banner: string;
|
||||
manga_type_id: number;
|
||||
reading_direction: string;
|
||||
story_status: number;
|
||||
translation_status: number;
|
||||
vols: number;
|
||||
chaps: number;
|
||||
reviewed: boolean;
|
||||
banned: boolean;
|
||||
rating: string;
|
||||
rates_count: number;
|
||||
commentable: boolean;
|
||||
show_comments: boolean;
|
||||
deleted_at: string | null;
|
||||
delete_reason: string;
|
||||
time_stamp: number;
|
||||
latest_chapterization_id: number;
|
||||
uniq_visitors_count: number;
|
||||
publisher_id: number | null;
|
||||
publisher_name: string | null;
|
||||
discord_url: string | null;
|
||||
mobile_exclusive: boolean;
|
||||
authors: any[];
|
||||
artists: any[];
|
||||
categories: Category[];
|
||||
type: Type;
|
||||
};
|
||||
type Release = {
|
||||
id: number;
|
||||
manga_id: number;
|
||||
created_at: string;
|
||||
time_stamp: number;
|
||||
views: number;
|
||||
encoded: boolean;
|
||||
showable: boolean;
|
||||
link_control: number;
|
||||
support_link: string;
|
||||
creator_id: number;
|
||||
chapterization_id: number;
|
||||
chapter: number;
|
||||
volume: number;
|
||||
title: string;
|
||||
team_id: number;
|
||||
team_name: string;
|
||||
team_rating: string;
|
||||
team_settings: string;
|
||||
team_paypal: string | null;
|
||||
has_rev_link: boolean;
|
||||
manga: Manga;
|
||||
};
|
||||
type ApiResponse = {
|
||||
releases: Release[];
|
||||
data: searchManga[];
|
||||
};
|
||||
type MangaCategory = {
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
manga_id: number;
|
||||
};
|
||||
type AuthorArtist = {
|
||||
id: number;
|
||||
name: string;
|
||||
manga_id: number;
|
||||
role: string;
|
||||
};
|
||||
|
||||
type MangaType = {
|
||||
id: number;
|
||||
name: string;
|
||||
reading_direction: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type MangaData = {
|
||||
id: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
is_novel: boolean;
|
||||
is_oneshot: boolean;
|
||||
genre_id: number;
|
||||
cover: string;
|
||||
cover_pos: number;
|
||||
rectangle_cover_pos: number;
|
||||
banner: string;
|
||||
manga_type_id: number;
|
||||
reading_direction: string;
|
||||
story_status: number;
|
||||
translation_status: number;
|
||||
vols: number;
|
||||
chaps: number;
|
||||
reviewed: boolean;
|
||||
banned: boolean;
|
||||
rating: string;
|
||||
rates_count: number;
|
||||
commentable: boolean;
|
||||
show_comments: boolean;
|
||||
deleted_at: string | null;
|
||||
delete_reason: string | null;
|
||||
time_stamp: number;
|
||||
latest_chapterization_id: number;
|
||||
uniq_visitors_count: number;
|
||||
publisher_id: number | null;
|
||||
publisher_name: string | null;
|
||||
arabic_title: string;
|
||||
english: string;
|
||||
synonyms: string;
|
||||
japanese: string;
|
||||
over17: boolean;
|
||||
s_date: string;
|
||||
e_date: string | null;
|
||||
mal: string;
|
||||
mangaupdates: string;
|
||||
mangadex: string | null;
|
||||
anilist: string;
|
||||
official_site: string | null;
|
||||
manga_fox: string | null;
|
||||
wikia: string | null;
|
||||
mangarock_oid: string | null;
|
||||
comicvine_id: string | null;
|
||||
external_source: string | null;
|
||||
discord_url: string | null;
|
||||
discord_id: string | null;
|
||||
anime_start_end: string | null;
|
||||
creator_id: number;
|
||||
creator_nick: string;
|
||||
editor_id: number;
|
||||
editor_nick: string;
|
||||
mobile_exclusive: boolean;
|
||||
authors: AuthorArtist[];
|
||||
artists: AuthorArtist[];
|
||||
categories: MangaCategory[];
|
||||
type: MangaType;
|
||||
memberRating: number;
|
||||
};
|
||||
|
||||
type MangaResponse = {
|
||||
membersMentioning: any[];
|
||||
memberRates: any | null;
|
||||
mangaLogs: Record<string, any>;
|
||||
mangaData: MangaData;
|
||||
};
|
||||
type ChapterRelease = {
|
||||
id: number;
|
||||
manga_id: number;
|
||||
created_at: string; // ISO 8601 date string
|
||||
time_stamp: number;
|
||||
views: number;
|
||||
link_control: number;
|
||||
support_link: string;
|
||||
init_team: number;
|
||||
creator_id: number;
|
||||
chapterization_id: number;
|
||||
chapterization_time_stamp: number;
|
||||
chapter: number;
|
||||
volume: number;
|
||||
title: string;
|
||||
team_id: number;
|
||||
team_name: string;
|
||||
has_rev_link: boolean;
|
||||
};
|
||||
type searchManga = {
|
||||
filter: any;
|
||||
id: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
is_novel: boolean;
|
||||
is_oneshot: boolean;
|
||||
genre_id: number;
|
||||
cover: string;
|
||||
cover_pos: number;
|
||||
rectangle_cover_pos: number;
|
||||
banner: string;
|
||||
manga_type_id: number;
|
||||
reading_direction: string;
|
||||
story_status: number;
|
||||
translation_status: number;
|
||||
vols: number;
|
||||
chaps: number;
|
||||
reviewed: boolean;
|
||||
banned: boolean;
|
||||
rating: string;
|
||||
rates_count: number;
|
||||
commentable: boolean;
|
||||
show_comments: boolean;
|
||||
deleted_at: string | null;
|
||||
delete_reason: string;
|
||||
time_stamp: number;
|
||||
latest_chapterization_id: number;
|
||||
uniq_visitors_count: number;
|
||||
publisher_id: number | null;
|
||||
publisher_name: string | null;
|
||||
discord_url: string | null;
|
||||
mobile_exclusive: boolean;
|
||||
authors: any[];
|
||||
artists: any[];
|
||||
categories: Category[];
|
||||
type: Type;
|
||||
};
|
||||
@@ -0,0 +1,264 @@
|
||||
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 RewayatClub implements Plugin.PagePlugin {
|
||||
id = 'rewayatclub';
|
||||
name = 'Rewayat Club';
|
||||
version = '1.0.2';
|
||||
icon = 'src/ar/rewayatclub/icon.png';
|
||||
site = 'https://rewayat.club/';
|
||||
|
||||
parseNovels(data: NovelData): Plugin.NovelItem[] {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
if (data.results === undefined) return novels;
|
||||
data.results.map((item: NovelEntry) => {
|
||||
novels.push({
|
||||
name: item.arabic || item.novel?.arabic || 'novel',
|
||||
path: item.slug
|
||||
? `novel/${item.slug}`
|
||||
: item.novel
|
||||
? `novel/${item.novel.slug}`
|
||||
: 'novel',
|
||||
cover: item.poster_url
|
||||
? `https://api.rewayat.club/${item.poster_url.slice(1)}`
|
||||
: item.novel
|
||||
? `https://api.rewayat.club/${item.novel.poster_url.slice(1)}`
|
||||
: defaultCover,
|
||||
});
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ showLatestNovels, filters }: Plugin.PopularNovelsOptions<Filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `https://api.rewayat.club/api/novels/`;
|
||||
let body: NovelData = {
|
||||
count: 0,
|
||||
next: '',
|
||||
previous: '',
|
||||
results: [],
|
||||
};
|
||||
if (showLatestNovels) {
|
||||
link = `${this.site}api/chapters/weekly/list/?page=${page}`;
|
||||
body = await fetchApi(link).then(r => r.json());
|
||||
} else if (filters) {
|
||||
if (filters.categories.value !== '') {
|
||||
link += `?type=${filters.categories.value}`;
|
||||
}
|
||||
if (filters.sortOptions.value !== '') {
|
||||
link += `&ordering=${filters.sortOptions.value}`;
|
||||
}
|
||||
if (filters.genre.value.length > 0) {
|
||||
filters.genre.value.forEach((genre: string) => {
|
||||
link += `&genre=${genre}`;
|
||||
});
|
||||
}
|
||||
link += `&page=${page}`;
|
||||
body = await fetchApi(link).then(r => r.json());
|
||||
}
|
||||
return this.parseNovels(body);
|
||||
}
|
||||
|
||||
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('h1.primary--text span').text().trim() || 'Untitled',
|
||||
author: loadedCheerio('.novel-author').text().trim(),
|
||||
summary: loadedCheerio('div.text-pre-line span').text().trim(),
|
||||
totalPages: 1,
|
||||
chapters: [],
|
||||
};
|
||||
const statusWords = new Set(['مكتملة', 'متوقفة', 'مستمرة']);
|
||||
const mainGenres = Array.from(loadedCheerio('.v-slide-group__content a'))
|
||||
.map(el => loadedCheerio(el).text().trim())
|
||||
.join(',');
|
||||
const statusGenre = Array.from(
|
||||
loadedCheerio('div.v-slide-group__content span.v-chip__content'),
|
||||
)
|
||||
.map(el => loadedCheerio(el).text().trim())
|
||||
.filter(text => statusWords.has(text));
|
||||
novel.genres = `${statusGenre},${mainGenres}`;
|
||||
const statusText = Array.from(
|
||||
loadedCheerio('div.v-slide-group__content span.v-chip__content'),
|
||||
)
|
||||
.map(el => loadedCheerio(el).text().trim())
|
||||
.filter(text => statusWords.has(text))
|
||||
.join();
|
||||
novel.status =
|
||||
{
|
||||
'متوقفة': 'On Hiatus',
|
||||
'مكتملة': 'Completed',
|
||||
'مستمرة': 'Ongoing',
|
||||
}[statusText] || 'Unknown';
|
||||
const imageRaw = loadedCheerio('body script:contains("__NUXT__")')
|
||||
.first()
|
||||
.text();
|
||||
const imageUrlRegex = /poster_url:"(\\u002F[^"]+)"/;
|
||||
const imageUrlMatch = imageRaw?.match(imageUrlRegex);
|
||||
const ImageUrlShort = imageUrlMatch
|
||||
? imageUrlMatch[1].replace(/\\u002F/g, '/').replace(/^\/*/, '')
|
||||
: defaultCover;
|
||||
const imageUrl = `https://api.rewayat.club/${ImageUrlShort}`;
|
||||
novel.cover = imageUrl;
|
||||
const chapterNumberStr = loadedCheerio('div.v-tab--active span.mr-1')
|
||||
.text()
|
||||
.replace(/[^\d]/g, '');
|
||||
const chapterNumber = parseInt(chapterNumberStr, 10);
|
||||
const pageNumber = Math.ceil(chapterNumber / 24);
|
||||
novel.totalPages = pageNumber;
|
||||
|
||||
return novel;
|
||||
}
|
||||
parseChapters(data: ChapterData, novelPath: string) {
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
data.results.map((item: ChapterEntry) => {
|
||||
chapter.push({
|
||||
name: item.title,
|
||||
releaseTime: new Date(item.date).toISOString(),
|
||||
path: `${novelPath}/${item.number}`,
|
||||
chapterNumber: item.number,
|
||||
});
|
||||
});
|
||||
return chapter;
|
||||
}
|
||||
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
|
||||
const pagePath = novelPath.slice(6);
|
||||
const pageUrl = `https://api.rewayat.club/api/chapters/${pagePath}/?ordering=number&page=${page}`;
|
||||
const dataJson = await fetchApi(pageUrl).then(r => r.json());
|
||||
const chapters = this.parseChapters(dataJson, novelPath);
|
||||
return {
|
||||
chapters,
|
||||
};
|
||||
}
|
||||
async parseChapter(chapterUrl: string): Promise<string> {
|
||||
const link = this.site + 'api/chapters/' + chapterUrl.slice(6);
|
||||
const result = await fetchApi(link).then(r => r.json());
|
||||
let chapterText = result.content
|
||||
.flat()
|
||||
.join('<br>')
|
||||
.replace(/\n/g, '')
|
||||
.replace(/<p>/g, '\n');
|
||||
chapterText = chapterText.trim();
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const searchUrl = `https://api.rewayat.club/api/novels/?type=0&ordering=-num_chapters&page=${page}&search=${searchTerm}`;
|
||||
|
||||
const result = await fetchApi(searchUrl).then(r => r.json());
|
||||
return this.parseNovels(result);
|
||||
}
|
||||
|
||||
filters = {
|
||||
genre: {
|
||||
value: [],
|
||||
label: 'Genres',
|
||||
options: [
|
||||
{ label: 'كوميديا', value: '1' }, // Comedy
|
||||
{ label: 'أكشن', value: '2' }, // Action
|
||||
{ label: 'دراما', value: '3' }, // Drama
|
||||
{ label: 'فانتازيا', value: '4' }, // Fantasy
|
||||
{ label: 'مهارات القتال', value: '5' }, // Combat Skills
|
||||
{ label: 'مغامرة', value: '6' }, // Adventure
|
||||
{ label: 'رومانسي', value: '7' }, // Romance
|
||||
{ label: 'خيال علمي', value: '8' }, // Science Fiction
|
||||
{ label: 'الحياة المدرسية', value: '9' }, // School Life
|
||||
{ label: 'قوى خارقة', value: '10' }, // Super Powers
|
||||
{ label: 'سحر', value: '11' }, // Magic
|
||||
{ label: 'رياضة', value: '12' }, // Sports
|
||||
{ label: 'رعب', value: '13' }, // Horror
|
||||
{ label: 'حريم', value: '14' }, // Harem
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
categories: {
|
||||
value: '0',
|
||||
label: 'الفئات',
|
||||
options: [
|
||||
{ label: 'جميع الروايات', value: '0' },
|
||||
{ label: 'مترجمة', value: '1' },
|
||||
{ label: 'مؤلفة', value: '2' },
|
||||
{ label: 'مكتملة', value: '3' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
sortOptions: {
|
||||
value: '-num_chapters',
|
||||
label: 'الترتيب',
|
||||
options: [
|
||||
{ label: 'عدد الفصول - من أقل ﻷعلى', value: 'num_chapters' },
|
||||
{ label: 'عدد الفصول - من أعلى ﻷقل', value: '-num_chapters' },
|
||||
{ label: 'الاسم - من أقل ﻷعلى', value: 'english' },
|
||||
{ label: 'الاسم - من أعلى ﻷقل', value: '-english' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new RewayatClub();
|
||||
|
||||
type NovelEntry = {
|
||||
arabic: string;
|
||||
english: string;
|
||||
about: string;
|
||||
poster_url: string;
|
||||
slug: string;
|
||||
original: boolean;
|
||||
complete: boolean;
|
||||
num_chapters: number;
|
||||
genre: {
|
||||
id: number;
|
||||
arabic: string;
|
||||
english: string;
|
||||
};
|
||||
novel?: {
|
||||
arabic: string;
|
||||
english: string;
|
||||
slug: string;
|
||||
poster: string;
|
||||
id: number;
|
||||
poster_url: string;
|
||||
original: boolean;
|
||||
};
|
||||
};
|
||||
type NovelData = {
|
||||
count?: number;
|
||||
next?: string;
|
||||
previous?: string;
|
||||
results: NovelEntry[];
|
||||
};
|
||||
type ChapterEntry = {
|
||||
number: number;
|
||||
title: string;
|
||||
date: string;
|
||||
uploader: {
|
||||
username: string;
|
||||
id: number;
|
||||
};
|
||||
hitcounts: {
|
||||
hits: number;
|
||||
id: number;
|
||||
};
|
||||
read: any[];
|
||||
};
|
||||
|
||||
type ChapterData = {
|
||||
count: number;
|
||||
next: string;
|
||||
previous: string;
|
||||
results: ChapterEntry[];
|
||||
};
|
||||
@@ -0,0 +1,306 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,297 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { fetchText } from '@/lib/fetch';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class Shu69 implements Plugin.PluginBase {
|
||||
private fetchOptions = {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0',
|
||||
'Accept':
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,us;q=0.5',
|
||||
'Referer': 'https://www.69shu.xyz/', // Referer
|
||||
'DNT': '1', // Do Not Track
|
||||
'Upgrade-Insecure-Requests': '1', // Upgrade-Insecure-Requests
|
||||
},
|
||||
};
|
||||
|
||||
id = '69shu';
|
||||
name = '69书吧';
|
||||
icon = 'src/cn/69shu/icon.png';
|
||||
site = 'https://www.69shu.xyz';
|
||||
version = '0.2.2';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url: string;
|
||||
if (showLatestNovels) {
|
||||
url = `${this.site}/rank/lastupdate/${pageNo}.html`;
|
||||
} else if (filters.sort.value === 'none') {
|
||||
url = `${this.site}/rank/${filters.rank.value}/${pageNo}.html`;
|
||||
} else {
|
||||
url = `${this.site}/sort/${filters.sort.value}/${pageNo}.html`;
|
||||
}
|
||||
|
||||
const body = await fetchText(url, this.fetchOptions);
|
||||
if (body === '') throw Error('无法获取小说列表,请检查网络');
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('div.book-coverlist').each((i, el) => {
|
||||
const url = loadedCheerio(el).find('a.cover').attr('href');
|
||||
|
||||
const novelName = loadedCheerio(el).find('h4.name').text().trim();
|
||||
const novelCover = loadedCheerio(el).find('a.cover > img').attr('src');
|
||||
if (!url) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: url.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const url = this.site + novelPath;
|
||||
|
||||
const body = await fetchText(url, this.fetchOptions);
|
||||
if (body === '') throw Error('无法获取小说内容,请检查网络');
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
chapters: [],
|
||||
name: loadedCheerio('h1').text().trim(),
|
||||
};
|
||||
|
||||
novel.cover = loadedCheerio('div.cover > img').attr('src');
|
||||
|
||||
novel.summary = loadedCheerio('#bookIntro').text().trim();
|
||||
|
||||
const bookInfo = loadedCheerio('div.caption-bookinfo > p');
|
||||
|
||||
novel.author = bookInfo.find('a').attr('title');
|
||||
|
||||
novel.artist = undefined;
|
||||
|
||||
novel.status = bookInfo.text().includes('连载')
|
||||
? NovelStatus.Ongoing
|
||||
: NovelStatus.Completed;
|
||||
|
||||
novel.genres = '';
|
||||
|
||||
// Table of Content is on a different page than the summary page
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
const allUrl = loadedCheerio('dd.all > a').attr('href');
|
||||
if (allUrl) {
|
||||
// --- Start: Fetch chapters with pagination (Sequential) ---
|
||||
let currentChaptersUrl = new URL(allUrl, this.site).toString();
|
||||
let hasMorePages = true;
|
||||
|
||||
while (hasMorePages) {
|
||||
const chaptersBody = await fetchText(
|
||||
currentChaptersUrl,
|
||||
this.fetchOptions,
|
||||
);
|
||||
const chaptersLoadedCheerio = parseHTML(chaptersBody);
|
||||
|
||||
// Extract chapters from the current page
|
||||
chaptersLoadedCheerio('dl.panel-chapterlist dd').each((i, el) => {
|
||||
const chapterUrl = chaptersLoadedCheerio(el).find('a').attr('href');
|
||||
const chapterName = chaptersLoadedCheerio(el).find('a').text().trim();
|
||||
if (chapterUrl) {
|
||||
// Ensure relative path, handle both absolute/relative cases
|
||||
const relativeChapterUrl = chapterUrl.startsWith('http')
|
||||
? chapterUrl.replace(this.site, '')
|
||||
: chapterUrl;
|
||||
// Avoid duplicates if the same chapter appears on multiple pages (unlikely but safe)
|
||||
if (!chapters.some(chap => chap.path === relativeChapterUrl)) {
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: relativeChapterUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Find the link to the next page using the text "下一页"
|
||||
const nextPageLinkElement = chaptersLoadedCheerio(
|
||||
'div.listpage a.onclick',
|
||||
).filter((i, el) =>
|
||||
chaptersLoadedCheerio(el).text().includes('下一页'),
|
||||
);
|
||||
const nextPageLink = nextPageLinkElement.attr('href');
|
||||
|
||||
if (nextPageLink && nextPageLink !== 'javascript:void(0);') {
|
||||
// Check if it's a valid relative or absolute URL before creating the URL object
|
||||
try {
|
||||
const absoluteNextPageUrl = new URL(
|
||||
nextPageLink,
|
||||
this.site,
|
||||
).toString();
|
||||
if (absoluteNextPageUrl === currentChaptersUrl) {
|
||||
// Break if the next page URL is the same as the current one (prevents infinite loops)
|
||||
hasMorePages = false;
|
||||
} else {
|
||||
currentChaptersUrl = absoluteNextPageUrl;
|
||||
}
|
||||
} catch (e) {
|
||||
// Handle cases where the link might be invalid or unexpected
|
||||
console.warn(`Invalid next page link found: ${nextPageLink}`);
|
||||
hasMorePages = false;
|
||||
}
|
||||
} else {
|
||||
hasMorePages = false;
|
||||
}
|
||||
}
|
||||
// --- End: Fetch chapters with pagination (Sequential) ---
|
||||
} else {
|
||||
// Fallback if no "all chapters" link is found
|
||||
loadedCheerio(
|
||||
'div.panel.hidden-xs > dl.panel-chapterlist:nth-child(2) > dd',
|
||||
).each((i, el) => {
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href');
|
||||
const chapterName = loadedCheerio(el).find('a').text().trim();
|
||||
if (chapterUrl) {
|
||||
const relativeChapterUrl = chapterUrl.startsWith('http')
|
||||
? chapterUrl.replace(this.site, '')
|
||||
: chapterUrl;
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: relativeChapterUrl,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Remove duplicates just in case (though less likely with sequential fetching)
|
||||
const uniqueChapters = chapters.filter(
|
||||
(chapter, index, self) =>
|
||||
index === self.findIndex(c => c.path === chapter.path),
|
||||
);
|
||||
|
||||
novel.chapters = uniqueChapters;
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const chapterUrl = new URL(chapterPath, this.site).toString();
|
||||
const body = await fetchText(chapterUrl, this.fetchOptions); // Header hinzugefügt
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const chapterText = loadedCheerio('#chaptercontent p')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.get()
|
||||
// remove empty lines and 69shu ads
|
||||
.map((line: string) => line.trim())
|
||||
.filter((line: string) => line !== '' && !line.includes('69书吧'))
|
||||
.map((line: string) => `<p>${line}</p>`)
|
||||
.join('\n');
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo > 1) return [];
|
||||
|
||||
const searchUrl = `${this.site}/search`;
|
||||
const formData = new FormData();
|
||||
formData.append('searchkey', searchTerm);
|
||||
|
||||
const searchOptions = {
|
||||
...this.fetchOptions,
|
||||
method: 'post',
|
||||
body: formData,
|
||||
headers: {
|
||||
...this.fetchOptions.headers,
|
||||
},
|
||||
};
|
||||
|
||||
const body = await fetchText(searchUrl, searchOptions);
|
||||
if (body === '') throw Error('无法获取搜索结果,请检查网络');
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('div.book-coverlist').each((i, el) => {
|
||||
const url = loadedCheerio(el).find('a.cover').attr('href');
|
||||
|
||||
const novelName = loadedCheerio(el).find('h4.name').text().trim();
|
||||
const novelCover = loadedCheerio(el).find('a.cover > img').attr('src');
|
||||
|
||||
if (!url) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: url.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
rank: {
|
||||
label: '排行榜',
|
||||
value: 'allvisit',
|
||||
options: [
|
||||
{ label: '总排行榜', value: 'allvisit' },
|
||||
{ label: '月排行榜', value: 'monthvisit' },
|
||||
{ label: '周排行榜', value: 'weekvisit' },
|
||||
{ label: '日排行榜', value: 'dayvisit' },
|
||||
{ label: '收藏榜', value: 'goodnum' },
|
||||
{ label: '字数榜', value: 'words' },
|
||||
{ label: '推荐榜', value: 'allvote' },
|
||||
{ label: '新书榜', value: 'postdate' },
|
||||
{ label: '更新榜', value: 'lastupdate' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
sort: {
|
||||
label: '分类',
|
||||
value: 'none',
|
||||
options: [
|
||||
{ label: '无', value: 'none' },
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '玄幻', value: 'xuanhuan' },
|
||||
{ label: '仙侠', value: 'xianxia' },
|
||||
{ label: '都市', value: 'dushi' },
|
||||
{ label: '历史', value: 'lishi' },
|
||||
{ label: '游戏', value: 'youxi' },
|
||||
{ label: '科幻', value: 'kehuan' },
|
||||
{ label: '灵异', value: 'kongbu' },
|
||||
{ label: '言情', value: 'nvsheng' },
|
||||
{ label: '其它', value: 'qita' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new Shu69();
|
||||
@@ -0,0 +1,201 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { fetchText } from '@/lib/fetch';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { encode } from 'urlencode';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class XinShu69 implements Plugin.PluginBase {
|
||||
id = '69xinshu';
|
||||
name = '69书吧';
|
||||
icon = 'src/cn/69xinshu/icon.png';
|
||||
site = 'https://69shu.biz';
|
||||
version = '0.1.2';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url: string;
|
||||
if (pageNo === 1) {
|
||||
url = `${this.site}/novels/class/${filters.class.value}.htm`;
|
||||
} else {
|
||||
url = `${this.site}/ajax_novels/class/${filters.class.value}/${pageNo}.htm`;
|
||||
}
|
||||
|
||||
const body = await fetchText(url, {}, 'gbk');
|
||||
if (body === '') throw Error('无法获取小说列表,请检查网络');
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
const novelsList =
|
||||
pageNo === 1
|
||||
? loadedCheerio('.newlistbox > ul > li')
|
||||
: loadedCheerio('li');
|
||||
|
||||
novelsList.each((i, e) => {
|
||||
const novelUrl = loadedCheerio(e).find('div > h3 > a').attr('href');
|
||||
if (novelUrl) {
|
||||
const novelName = loadedCheerio(e).find('div > h3 > a').text();
|
||||
const novelCover = loadedCheerio(e).find('a > img').attr('data-src');
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const url = this.site + novelPath;
|
||||
|
||||
const body = await fetchText(url, {}, 'gbk');
|
||||
if (body === '') throw Error('无法获取小说内容,请检查网络');
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
chapters: [],
|
||||
name: loadedCheerio('h1 > a').text(),
|
||||
};
|
||||
|
||||
novel.cover = loadedCheerio('div.bookimg2 > img').attr('src');
|
||||
|
||||
novel.summary = loadedCheerio('div.navtxt').text().trim();
|
||||
|
||||
const bookInfo = loadedCheerio('div.booknav2').text();
|
||||
|
||||
const authorMatch = bookInfo.match(/作者:(.*)/);
|
||||
if (authorMatch) {
|
||||
novel.author = authorMatch[1];
|
||||
}
|
||||
|
||||
novel.artist = undefined;
|
||||
|
||||
novel.status = bookInfo.includes('连载')
|
||||
? NovelStatus.Ongoing
|
||||
: NovelStatus.Completed;
|
||||
|
||||
const genresMatch = bookInfo.match(/分类:(.*)/);
|
||||
if (genresMatch) {
|
||||
novel.genres = genresMatch[1];
|
||||
}
|
||||
|
||||
// Table of Content is on a different page than the summary page
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
const chaptersUrl = loadedCheerio('a.more-btn').attr('href');
|
||||
if (chaptersUrl) {
|
||||
const chaptersBody = await fetchText(chaptersUrl, {}, 'gbk');
|
||||
|
||||
const chaptersLoadedCheerio = parseHTML(chaptersBody);
|
||||
|
||||
chaptersLoadedCheerio('li').each((i, e) => {
|
||||
const chapterUrl = chaptersLoadedCheerio(e).find('a').attr('href');
|
||||
|
||||
if (chapterUrl?.startsWith('https://')) {
|
||||
const chapterName = chaptersLoadedCheerio(e).find('a').text().trim();
|
||||
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
novel.chapters = chapters;
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchText(this.site + chapterPath, {}, 'gbk');
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const chapterText = (loadedCheerio('div.txtnav').prop('innerText') ?? '')
|
||||
.split('\n')
|
||||
// remove empty lines
|
||||
.map((line: string) => line.trim())
|
||||
.filter((line: string) => line !== '')
|
||||
// remove the first two lines which are the chapter name and author name
|
||||
.slice(2)
|
||||
.map((line: string) => `<p>${line}</p>`)
|
||||
.join('\n');
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo > 1) return [];
|
||||
|
||||
const searchUrl = `${this.site}/modules/article/search.php`;
|
||||
const formData = new FormData();
|
||||
formData.append('searchkey', encode(searchTerm, 'gbk'));
|
||||
formData.append('searchtype', 'all');
|
||||
|
||||
const body = await fetchText(
|
||||
searchUrl,
|
||||
{ method: 'post', body: formData },
|
||||
'gbk',
|
||||
);
|
||||
if (body === '') throw Error('无法获取搜索结果,请检查网络');
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.newbox > ul > li').each((i, e) => {
|
||||
const novelUrl = loadedCheerio(e).find('a').attr('href')!;
|
||||
const novelName = loadedCheerio(e).find('div.newnav > h3').text();
|
||||
const novelCover = loadedCheerio(e).find('img').attr('data-src');
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
cover: novelCover,
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
class: {
|
||||
label: '分类',
|
||||
value: '0',
|
||||
options: [
|
||||
{ label: '全部分类', value: '0' },
|
||||
{ label: '言情小说', value: '3' },
|
||||
{ label: '玄幻魔法', value: '1' },
|
||||
{ label: '修真武侠', value: '2' },
|
||||
{ label: '穿越时空', value: '11' },
|
||||
{ label: '都市小说', value: '9' },
|
||||
{ label: '历史军事', value: '4' },
|
||||
{ label: '游戏竞技', value: '5' },
|
||||
{ label: '科幻空间', value: '6' },
|
||||
{ label: '悬疑惊悚', value: '7' },
|
||||
{ label: '同人小说', value: '8' },
|
||||
{ label: '官场职场', value: '10' },
|
||||
{ label: '青春校园', value: '12' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new XinShu69();
|
||||
@@ -0,0 +1,415 @@
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { fetchApi, fetchFile } from '@/lib/fetch';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
|
||||
const getStandardNovelPath = (url: string | undefined): string | undefined => {
|
||||
if (!url) return undefined;
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
const match = parsedUrl.pathname.match(/^(\/amp)?(\/n\/[^\/]+\/)/);
|
||||
return match?.[2];
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getChapterFileName = (url: string | undefined): string | undefined => {
|
||||
if (!url) return undefined;
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
const pathParts = parsedUrl.pathname.split('/');
|
||||
const fileName = pathParts[pathParts.length - 1];
|
||||
if (fileName && /^\d+\.html$/.test(fileName)) {
|
||||
return fileName;
|
||||
}
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const makeAbsolute = (
|
||||
relativeUrl: string | undefined,
|
||||
baseUrl: string,
|
||||
): string | undefined => {
|
||||
if (!relativeUrl) return undefined;
|
||||
try {
|
||||
if (relativeUrl.startsWith('//')) {
|
||||
return 'https:' + relativeUrl;
|
||||
}
|
||||
if (
|
||||
relativeUrl.startsWith('http://') ||
|
||||
relativeUrl.startsWith('https://')
|
||||
) {
|
||||
return relativeUrl;
|
||||
}
|
||||
return new URL(relativeUrl, baseUrl).href;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
class QuanbenPlugin implements Plugin.PluginBase {
|
||||
id = 'quanben';
|
||||
name = 'Quanben';
|
||||
site = 'https://www.quanben.io/';
|
||||
version = '1.0.0';
|
||||
icon = 'src/cn/quanben/icon.png';
|
||||
defaultCover = defaultCover;
|
||||
|
||||
filters = {} satisfies Filters;
|
||||
|
||||
async popularNovels(_pageNo: number): Promise<Plugin.NovelItem[]> {
|
||||
const url = this.site + 'amp/';
|
||||
const result = await fetchApi(url);
|
||||
if (!result.ok) {
|
||||
throw new Error(
|
||||
`[Quanben] Failed to fetch AMP popular novels page: ${url} - Status: ${result.status}`,
|
||||
);
|
||||
}
|
||||
const body = await result.text();
|
||||
const $ = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
const processedAmpPaths = new Set<string>();
|
||||
|
||||
$('div.box').each((_i, box) => {
|
||||
const $box = $(box);
|
||||
|
||||
// 1. Process the featured novel (div.list2) if it exists
|
||||
const $featured = $box.find('div.list2');
|
||||
if ($featured.length > 0) {
|
||||
const $link = $featured.find('h3 > a');
|
||||
const ampPath = $link.attr('href')?.trim();
|
||||
const name = $link.text().trim();
|
||||
const rawCoverSrc = $featured.find('amp-img').attr('src')?.trim();
|
||||
const cover = makeAbsolute(rawCoverSrc, this.site) || this.defaultCover;
|
||||
|
||||
if (ampPath && name && !processedAmpPaths.has(ampPath)) {
|
||||
novels.push({ name, path: ampPath, cover });
|
||||
processedAmpPaths.add(ampPath);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Process novels in the list (ul.list)
|
||||
$box.find('ul.list li').each((_j, listItem) => {
|
||||
const $listItem = $(listItem);
|
||||
const $link = $listItem.find('a');
|
||||
const ampPath = $link.attr('href')?.trim();
|
||||
const name = $link.find('span').first().text().trim(); // Name is inside a span within the link
|
||||
|
||||
if (ampPath && name && !processedAmpPaths.has(ampPath)) {
|
||||
novels.push({ name, path: ampPath, cover: this.defaultCover }); // Use default cover for list items
|
||||
processedAmpPaths.add(ampPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
// ** Expects AMP path: /amp/n/novel-name/ **
|
||||
if (
|
||||
!novelPath ||
|
||||
!novelPath.startsWith('/amp/n/') ||
|
||||
!novelPath.endsWith('/')
|
||||
) {
|
||||
throw new Error(
|
||||
`[Quanben parseNovel] Invalid novelPath received: "${novelPath}". Expected AMP format: "/amp/n/novel-name/"`,
|
||||
);
|
||||
}
|
||||
|
||||
// Construct full AMP novel URL
|
||||
const fullNovelUrl = makeAbsolute(novelPath, this.site);
|
||||
if (!fullNovelUrl) {
|
||||
throw new Error(
|
||||
`[Quanben parseNovel] Could not construct full AMP novel URL from path: ${novelPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Fetch AMP Novel Page HTML
|
||||
const novelPageResult = await fetchApi(fullNovelUrl);
|
||||
if (!novelPageResult.ok) {
|
||||
throw new Error(
|
||||
`[Quanben parseNovel] Failed to fetch AMP novel page: ${fullNovelUrl} - Status: ${novelPageResult.status}`,
|
||||
);
|
||||
}
|
||||
const novelPageHtml = await novelPageResult.text();
|
||||
const $ = parseHTML(novelPageHtml);
|
||||
|
||||
// 2. Parse Novel Details from AMP page - **Refined Selectors**
|
||||
const $infoBox = $('div.list2'); // Base element for many details
|
||||
const $descriptionBox = $('div.description'); // Separate element for summary
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath, // Use the input AMP path
|
||||
name:
|
||||
$infoBox.find('h3').text().trim() || // **Use h3 inside list2**
|
||||
$('h1[itemprop="name headline"]').text().trim() || // Fallback H1
|
||||
'Unknown Novel Name',
|
||||
cover:
|
||||
makeAbsolute($infoBox.find('amp-img').attr('src'), this.site) || // **amp-img inside list2**
|
||||
this.defaultCover,
|
||||
summary:
|
||||
$descriptionBox.find('p').text().trim() || // **Use p inside div.description**
|
||||
$descriptionBox.text().trim() || // Fallback to div.description text
|
||||
undefined,
|
||||
// Use :contains() for more robust selection within the info box
|
||||
author:
|
||||
$infoBox.find("p:contains('作者:') span").text().trim() || undefined,
|
||||
status: NovelStatus.Unknown, // Parsed below
|
||||
genres:
|
||||
$infoBox.find("p:contains('类别:') span").text().trim() || undefined,
|
||||
chapters: [], // Parsed below
|
||||
};
|
||||
|
||||
// Parse Status from AMP page
|
||||
const statusText =
|
||||
$infoBox.find("p:contains('状态:') span").text().trim() || // Try specific span first
|
||||
$infoBox.text(); // Fallback to full text
|
||||
if (statusText.includes('完结') || statusText.includes('已完成')) {
|
||||
novel.status = NovelStatus.Completed;
|
||||
} else if (statusText.includes('连载中') || statusText.includes('进行中')) {
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
}
|
||||
|
||||
// 3. Fetch and Parse Chapter List
|
||||
novel.chapters = await this.parseChapterList(novelPath);
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
// Separate function to handle chapter list parsing - **Using AMP**
|
||||
async parseChapterList(novelPath: string): Promise<Plugin.ChapterItem[]> {
|
||||
// ** Expects AMP path: /amp/n/novel-name/ **
|
||||
if (
|
||||
!novelPath ||
|
||||
!novelPath.startsWith('/amp/n/') ||
|
||||
!novelPath.endsWith('/')
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Construct the AMP chapter list URL
|
||||
const ampChapterListUrl = makeAbsolute(novelPath + 'list.html', this.site);
|
||||
if (!ampChapterListUrl) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const chapterListResult = await fetchApi(ampChapterListUrl);
|
||||
if (!chapterListResult.ok) {
|
||||
return [];
|
||||
}
|
||||
const chapterListHtml = await chapterListResult.text();
|
||||
const $ = parseHTML(chapterListHtml);
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
// Extract standard novel name from AMP path for chapter path storage
|
||||
const standardNovelPathMatch = novelPath.match(/(\/n\/[^\/]+\/)/);
|
||||
if (!standardNovelPathMatch || !standardNovelPathMatch[1]) {
|
||||
return [];
|
||||
}
|
||||
const novelNameOnly = standardNovelPathMatch[1].replace(/^\/n\/|\/$/g, '');
|
||||
|
||||
// 1. Parse chapters from ALL list3 uls in the AMP HTML
|
||||
$('ul.list3 li a').each((_i, el) => {
|
||||
const $el = $(el);
|
||||
const chapterName = $el.text().trim();
|
||||
const chapterHref = $el.attr('href');
|
||||
if (chapterName && chapterHref) {
|
||||
// URLs in AMP page usually point to the standard chapter URLs
|
||||
const absoluteUrl = makeAbsolute(chapterHref, this.site); // Use base site URL
|
||||
const chapterFileName = getChapterFileName(absoluteUrl);
|
||||
if (chapterFileName) {
|
||||
// ** Store chapter path in standard format for parseChapter **
|
||||
const chapterPathForStorage = novelNameOnly + '/' + chapterFileName;
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterPathForStorage,
|
||||
// chapterNumber assigned later
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Deduplicate chapters based on path
|
||||
const chapterMap = new Map<string, Plugin.ChapterItem>();
|
||||
chapters.forEach(chapter => {
|
||||
if (!chapterMap.has(chapter.path)) {
|
||||
chapterMap.set(chapter.path, chapter);
|
||||
}
|
||||
});
|
||||
const uniqueChapters = Array.from(chapterMap.values());
|
||||
|
||||
// 3. Sort chapters numerically based on filename
|
||||
uniqueChapters.sort((a, b) => {
|
||||
const numA = parseInt(a.path.match(/(\d+)\.html$/)?.[1] || '0', 10);
|
||||
const numB = parseInt(b.path.match(/(\d+)\.html$/)?.[1] || '0', 10);
|
||||
return numA - numB;
|
||||
});
|
||||
|
||||
// 4. Assign chapter numbers
|
||||
return uniqueChapters.map((chapter, index) => ({
|
||||
...chapter,
|
||||
chapterNumber: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
// ** Expects standard path: novel-name/chapterFileName.html **
|
||||
if (
|
||||
!chapterPath ||
|
||||
!chapterPath.includes('/') ||
|
||||
chapterPath.endsWith('/')
|
||||
) {
|
||||
throw new Error(
|
||||
`[Quanben] Invalid chapterPath format received in parseChapter: "${chapterPath}". Expected format: "novel-name/chapterFileName.html"`,
|
||||
);
|
||||
}
|
||||
|
||||
// Construct the standard chapter URL using /n/ prefix
|
||||
const chapterUrl = `${this.site}n/${chapterPath}`;
|
||||
|
||||
const result = await fetchApi(chapterUrl);
|
||||
if (!result.ok) {
|
||||
// Handle potential redirects (e.g., chapter moved) - check Location header if status is 3xx
|
||||
if (result.status >= 300 && result.status < 400) {
|
||||
const redirectUrl = result.headers.get('Location');
|
||||
if (redirectUrl) {
|
||||
const absoluteRedirectUrl = makeAbsolute(redirectUrl, chapterUrl);
|
||||
if (!absoluteRedirectUrl) {
|
||||
throw new Error(
|
||||
`[Quanben] Failed to make redirected URL absolute: ${redirectUrl}`,
|
||||
);
|
||||
}
|
||||
const redirectResult = await fetchApi(absoluteRedirectUrl);
|
||||
if (!redirectResult.ok) {
|
||||
throw new Error(
|
||||
`[Quanben] Failed to fetch redirected chapter content: ${absoluteRedirectUrl} - Status: ${redirectResult.status}`,
|
||||
);
|
||||
}
|
||||
return this.extractChapterContent(
|
||||
await redirectResult.text(),
|
||||
absoluteRedirectUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`[Quanben] Failed to fetch chapter content: ${chapterUrl} - Status: ${result.status}`,
|
||||
);
|
||||
}
|
||||
const body = await result.text();
|
||||
return this.extractChapterContent(body, chapterUrl);
|
||||
}
|
||||
|
||||
// Helper function to extract and clean chapter content from HTML body
|
||||
private extractChapterContent(body: string, urlForLog: string): string {
|
||||
const $ = parseHTML(body);
|
||||
let $content = $('#contentbody');
|
||||
if (!$content.length) {
|
||||
$content = $('#content');
|
||||
}
|
||||
if (!$content.length) {
|
||||
$content = $('.content');
|
||||
}
|
||||
if (!$content.length) {
|
||||
return 'Error: Could not find chapter content container.';
|
||||
}
|
||||
$content
|
||||
.find(
|
||||
'script, style, ins, iframe, [class*="ads"], [id*="ads"], [class*="google"], [id*="google"], [class*="recommend"], div[align="center"]',
|
||||
)
|
||||
.remove();
|
||||
$content.find('p').each((_i, el) => {
|
||||
const $p = $(el);
|
||||
const pText = $p.text().trim();
|
||||
if (
|
||||
pText.includes('请记住本书首发域名') ||
|
||||
pText.includes('手机版阅读网址') ||
|
||||
pText.includes('quanben') ||
|
||||
pText.includes('最新网址') ||
|
||||
pText.includes('章节报错') ||
|
||||
pText.match(/app|APP|下载|客户端/) ||
|
||||
pText.length === 0 ||
|
||||
($p
|
||||
.html()
|
||||
?.replace(/ /g, '')
|
||||
.trim() === '' &&
|
||||
$p.find('img').length === 0)
|
||||
) {
|
||||
$p.remove();
|
||||
}
|
||||
});
|
||||
$content
|
||||
.contents()
|
||||
.filter(function () {
|
||||
return this.type === 'comment';
|
||||
})
|
||||
.remove();
|
||||
let chapterText = $content.html();
|
||||
if (!chapterText) {
|
||||
return 'Error: Chapter content was empty after cleaning.';
|
||||
}
|
||||
chapterText = chapterText.replace(/<\s*p[^>]*>/gi, '\n\n');
|
||||
chapterText = chapterText.replace(/<\s*br[^>]*>/gi, '\n');
|
||||
chapterText = chapterText.replace(/<[^>]+>/g, '');
|
||||
chapterText = parseHTML(`<div>${chapterText}</div>`).text();
|
||||
chapterText = chapterText.replace(/[\t ]+/g, ' ');
|
||||
chapterText = chapterText.replace(/\n{3,}/g, '\n\n');
|
||||
chapterText = chapterText.trim();
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
_pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const searchUrl = `${this.site}index.php?c=book&a=search&keywords=${encodeURIComponent(searchTerm)}`;
|
||||
const result = await fetchApi(searchUrl);
|
||||
if (!result.ok) {
|
||||
return [];
|
||||
}
|
||||
const body = await result.text();
|
||||
const $ = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
$('div.list2').each((_i, element) => {
|
||||
const $el = $(element);
|
||||
const nameLink = $el.find('h3 > a').first();
|
||||
const img = $el.find('img').first();
|
||||
|
||||
const novelName = nameLink.text().trim();
|
||||
const novelHref = nameLink.attr('href');
|
||||
const novelCover = img.attr('src') || img.attr('data-src');
|
||||
|
||||
if (novelHref && novelName) {
|
||||
const absoluteUrl = makeAbsolute(novelHref, this.site);
|
||||
const standardPath = getStandardNovelPath(absoluteUrl);
|
||||
|
||||
if (standardPath) {
|
||||
// **Construct AMP Path for storage**
|
||||
const ampPath = '/amp' + standardPath;
|
||||
const absoluteCover = makeAbsolute(novelCover, this.site);
|
||||
novels.push({
|
||||
name: novelName,
|
||||
path: ampPath, // **Store the AMP path**
|
||||
cover: absoluteCover || this.defaultCover,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
// Use fetchApi for fetchImage as it handles potential errors and returns Response
|
||||
async fetchImage(url: string): Promise<Response> {
|
||||
return fetchApi(url);
|
||||
}
|
||||
}
|
||||
|
||||
export default new QuanbenPlugin();
|
||||
@@ -0,0 +1,300 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@/lib/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
const makeAbsolute = (
|
||||
relativeUrl: string | undefined,
|
||||
baseUrl: string,
|
||||
): string | undefined => {
|
||||
if (!relativeUrl) return undefined;
|
||||
try {
|
||||
if (relativeUrl.startsWith('//')) {
|
||||
return new URL(baseUrl).protocol + relativeUrl;
|
||||
}
|
||||
if (
|
||||
relativeUrl.startsWith('http://') ||
|
||||
relativeUrl.startsWith('https://')
|
||||
) {
|
||||
return relativeUrl;
|
||||
}
|
||||
return new URL(relativeUrl, baseUrl).href;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
class ixdzs8Plugin implements Plugin.PluginBase {
|
||||
id = 'ixdzs8';
|
||||
name = '爱下电子书';
|
||||
site = 'https://ixdzs8.com/';
|
||||
version = '2.2.8';
|
||||
icon = 'src/cn/ixdzs8/favicon.png';
|
||||
|
||||
imageRequestInit = {
|
||||
headers: {
|
||||
Referer: this.site,
|
||||
},
|
||||
};
|
||||
|
||||
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}hot/?page=${pageNo}`;
|
||||
const result = await fetchApi(url);
|
||||
if (!result.ok) return [];
|
||||
|
||||
const $ = parseHTML(await result.text());
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
const processedPaths = new Set<string>();
|
||||
|
||||
$('ul.u-list > li.burl').each((_i, el) => {
|
||||
const $el = $(el);
|
||||
let novelPath: string | undefined;
|
||||
let novelName: string | undefined;
|
||||
let novelCover: string | undefined;
|
||||
|
||||
const $link = $el.find('.l-info h3 a');
|
||||
novelPath = $link.attr('href')?.trim();
|
||||
novelName = ($link.attr('title') || $link.text() || '').trim();
|
||||
novelCover = $el.find('.l-img img').attr('src')?.trim();
|
||||
|
||||
if (novelPath && novelName) {
|
||||
novels.push({
|
||||
name: novelName,
|
||||
path: novelPath,
|
||||
cover: makeAbsolute(novelCover, this.site) || defaultCover,
|
||||
});
|
||||
processedPaths.add(novelPath);
|
||||
}
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novelUrl = makeAbsolute(novelPath, this.site);
|
||||
if (!novelUrl) throw new Error('Invalid novel URL');
|
||||
|
||||
const result = await fetchApi(novelUrl);
|
||||
if (!result.ok) throw new Error('Failed to fetch novel');
|
||||
|
||||
const $ = parseHTML(await result.text());
|
||||
const $novel = $('div.novel');
|
||||
const $intro = $('p#intro.pintro');
|
||||
// Remove any child elements you don't want, e.g., the "read more" icon
|
||||
$intro.find('span.icon').remove();
|
||||
// Get text and normalize whitespace
|
||||
const summary = $intro
|
||||
.html() // get inner HTML
|
||||
?.replace(/<br\s*\/?>/gi, '\n') // convert <br> to line breaks
|
||||
.replace(/<[^>]+>/g, '') // remove remaining tags like <font>
|
||||
.replace(/\u3000/g, ' ') // replace full-width spaces
|
||||
.replace(/ /g, ' ') // replace spaces
|
||||
.split('\n') // split into lines
|
||||
.map(line => line.trim()) // trim each line
|
||||
.filter(line => line.length > 0) // remove empty lines
|
||||
.join(' '); // join into one paragraph
|
||||
|
||||
const $tagsDiv = $('div.panel div.tags');
|
||||
// find all <a> inside <em> and get their text
|
||||
// Select <p> that contains <a.nsort>
|
||||
|
||||
const statOngoing = $novel.find('.n-text p span.lz').text().trim();
|
||||
const statEnd = $novel.find('.n-text p span.end').text().trim();
|
||||
let detail: 'Ongoing' | 'Completed' | 'Unknown' = 'Unknown';
|
||||
if (statEnd.length > 0) {
|
||||
detail = 'Completed';
|
||||
} else if (statOngoing.length > 0) {
|
||||
detail = 'Ongoing';
|
||||
} else {
|
||||
detail = 'Unknown';
|
||||
}
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: $novel.find('.n-text h1').text().trim() || 'Untitled',
|
||||
cover:
|
||||
makeAbsolute($novel.find('.n-img img').attr('src'), this.site) ||
|
||||
defaultCover,
|
||||
summary: summary,
|
||||
author: $novel.find('.n-text p a.bauthor').text().trim() || undefined,
|
||||
genres: $tagsDiv
|
||||
.find('em a')
|
||||
.map((_i, el) => $(el).text().trim())
|
||||
.get()
|
||||
.filter(tag => tag.length > 0) // remove empty strings
|
||||
.join(', '),
|
||||
status:
|
||||
detail === 'Ongoing'
|
||||
? NovelStatus.Ongoing
|
||||
: detail === 'Completed'
|
||||
? NovelStatus.Completed
|
||||
: NovelStatus.Unknown,
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
const chapterListPath = $('#bid').attr('value'); // get bid from page
|
||||
if (chapterListPath) {
|
||||
novel.chapters = await this.parseChapterList(chapterListPath);
|
||||
}
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapterList(bid: string | number): Promise<Plugin.ChapterItem[]> {
|
||||
// Convert bid to string just in case
|
||||
const bookId = String(bid);
|
||||
|
||||
// POST request to fetch chapter list JSON
|
||||
const url = `${this.site}novel/clist/`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `bid=${bookId}`,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch chapters for bid=${bookId}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (json.rs !== 200 || !Array.isArray(json.data)) {
|
||||
throw new Error('Invalid response format from chapter list');
|
||||
}
|
||||
|
||||
// Build chapters array
|
||||
const chapters: Plugin.ChapterItem[] = json.data.map((ch: any) => {
|
||||
return {
|
||||
name: ch.title,
|
||||
path: ch.ctype === '0' ? `read/${bookId}/p${ch.ordernum}.html` : '', // only normal chapters get link
|
||||
releaseTime: undefined, // optional, not provided here
|
||||
};
|
||||
});
|
||||
|
||||
return chapters;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const chapterUrl = makeAbsolute(chapterPath, this.site);
|
||||
if (!chapterUrl) throw new Error('Invalid chapter URL');
|
||||
|
||||
// --- 1st request ---
|
||||
let result = await fetchApi(chapterUrl);
|
||||
if (!result.ok) throw new Error(`Failed to fetch chapter at ${chapterUrl}`);
|
||||
|
||||
let html = await result.text();
|
||||
|
||||
// --- Check if we got challenge page ---
|
||||
if (html.includes('正在進行安全驗證') || html.includes('challenge')) {
|
||||
const tokenMatch = html.match(/let token\s*=\s*"([^"]+)"/);
|
||||
if (tokenMatch) {
|
||||
const challengeUrl =
|
||||
chapterUrl + '?challenge=' + encodeURIComponent(tokenMatch[1]);
|
||||
|
||||
result = await fetchApi(challengeUrl);
|
||||
if (!result.ok)
|
||||
throw new Error(`Failed after challenge redirect: ${challengeUrl}`);
|
||||
html = await result.text();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Parse content ---
|
||||
const $ = parseHTML(html);
|
||||
const $content = $('article section');
|
||||
|
||||
if (!$content.length) {
|
||||
return `Error: Could not find chapter content at ${chapterUrl}`;
|
||||
}
|
||||
|
||||
// Remove ads & junk
|
||||
$content
|
||||
.find(
|
||||
'script, style, ins, iframe, [class*="abg"], [class*="ads"], [id*="ads"], [class*="google"], [id*="google"], [class*="recommend"], div[align="center"], p:contains("推薦本書"), a[href*="javascript:"]',
|
||||
)
|
||||
.remove();
|
||||
|
||||
// Drop empty <p>
|
||||
$content.find('p').each((_i, el) => {
|
||||
const $p = $(el);
|
||||
if (!$p.text().trim()) $p.remove();
|
||||
});
|
||||
|
||||
// Remove HTML comments
|
||||
$content
|
||||
.contents()
|
||||
.filter(function () {
|
||||
return this.type === 'comment';
|
||||
})
|
||||
.remove();
|
||||
|
||||
// Unwrap <font>
|
||||
$content.find('font').each((_i, el) => {
|
||||
const $el = $(el);
|
||||
$el.replaceWith($el.html() || '');
|
||||
});
|
||||
|
||||
// Extract cleaned text
|
||||
let chapterText = $content.html();
|
||||
if (!chapterText) return 'Error: Chapter content was empty';
|
||||
|
||||
chapterText = chapterText
|
||||
.replace(/<\s*p[^>]*>/gi, '\n\n')
|
||||
.replace(/<\s*br[^>]*>/gi, '\n');
|
||||
|
||||
chapterText = parseHTML(`<div>${chapterText}</div>`).text();
|
||||
|
||||
return chapterText
|
||||
.replace(/[\t ]+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const searchUrl = `${this.site}bsearch?q=${encodeURIComponent(searchTerm)}`;
|
||||
let body = '';
|
||||
|
||||
try {
|
||||
const result = await fetchApi(searchUrl);
|
||||
if (!result.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch search results: HTTP ${result.status}`,
|
||||
);
|
||||
}
|
||||
body = await result.text();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Failed to fetch search results: ${error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const $ = parseHTML(body);
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
$('ul.u-list li.burl').each((_i, el) => {
|
||||
const $el = $(el);
|
||||
|
||||
const novelPath = $el.attr('data-url')?.trim();
|
||||
const novelName = $el.find('h3.bname a').text().trim();
|
||||
const novelCover = $el.find('.l-img img').attr('src')?.trim();
|
||||
|
||||
if (novelPath && novelName) {
|
||||
novels.push({
|
||||
name: novelName,
|
||||
path: novelPath,
|
||||
cover: makeAbsolute(novelCover, this.site) || defaultCover,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new ixdzs8Plugin();
|
||||
@@ -0,0 +1,166 @@
|
||||
import { fetchText } from '@/lib/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class LinovelPlugin implements Plugin.PluginBase {
|
||||
id = 'linovel';
|
||||
name = 'linovel';
|
||||
icon = 'src/cn/linovel/icon.png';
|
||||
site = 'https://www.linovel.net';
|
||||
version = '1.0.0';
|
||||
filters: Filters | undefined = undefined;
|
||||
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
|
||||
|
||||
//flag indicates whether access to LocalStorage, SesesionStorage is required.
|
||||
webStorageUtilized?: boolean;
|
||||
|
||||
private userAgent =
|
||||
'Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0';
|
||||
|
||||
private async fetchHTML(url: string) {
|
||||
const body = await fetchText(url, {
|
||||
headers: { 'User-Agent': this.userAgent },
|
||||
});
|
||||
if (body === '') throw Error('无法获取内容,请检查网络');
|
||||
return parseHTML(body);
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
const loadedCheerio = await this.fetchHTML(this.site);
|
||||
|
||||
loadedCheerio('a.book-item-inner').each((i, elem) => {
|
||||
const name = loadedCheerio(elem).find('.book-item-name').text().trim();
|
||||
const path = loadedCheerio(elem).attr('href') ?? '';
|
||||
const cover = (
|
||||
loadedCheerio(elem).find('img').attr('data-original') ?? ''
|
||||
).replace(/!min300jpg$/, '');
|
||||
|
||||
novels.push({ name, path, cover });
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: 'Untitled',
|
||||
};
|
||||
|
||||
const loadedCheerio = await this.fetchHTML(this.site + novelPath);
|
||||
|
||||
loadedCheerio('div.name')
|
||||
.find('a')
|
||||
.each((_i, elem) => {
|
||||
novel.author = loadedCheerio(elem).text().trim();
|
||||
});
|
||||
|
||||
loadedCheerio('.book-title').each((_i, elem) => {
|
||||
novel.name = loadedCheerio(elem).text().trim();
|
||||
});
|
||||
|
||||
loadedCheerio('.book-data')
|
||||
.find('span')
|
||||
.each((_i, elem) => {
|
||||
const text = loadedCheerio(elem).text().trim();
|
||||
if (text.includes('字数')) {
|
||||
// Handle word count
|
||||
} else if (text.includes('热度')) {
|
||||
// Handle popularity
|
||||
} else if (text.includes('收藏')) {
|
||||
// Handle favorites
|
||||
} else if (text === '连载中') {
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
} else if (text === '已完结') {
|
||||
novel.status = NovelStatus.Completed;
|
||||
}
|
||||
});
|
||||
|
||||
loadedCheerio('.book-cover')
|
||||
.find('img')
|
||||
.each((_i, elem) => {
|
||||
novel.cover = loadedCheerio(elem).attr('src') ?? '';
|
||||
});
|
||||
|
||||
novel.genres = loadedCheerio('.book-cats')
|
||||
.children('a')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
novel.summary = loadedCheerio('.about-text').text().trim();
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('div.chapter')
|
||||
.find('a')
|
||||
.each((i, elem) => {
|
||||
const name = loadedCheerio(elem).text().trim();
|
||||
const path = loadedCheerio(elem).attr('href') ?? '';
|
||||
chapters.push({ name, path });
|
||||
});
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const loadedCheerio = await this.fetchHTML(this.site + chapterPath);
|
||||
let chapterText = '';
|
||||
|
||||
if (loadedCheerio('.fufei-app-download-hint').length) {
|
||||
throw Error('本章节需订阅后才能阅览,请下载轻之文库App订阅');
|
||||
}
|
||||
|
||||
loadedCheerio('.article-text')
|
||||
.find('p, img')
|
||||
.each((i, elem) => {
|
||||
if (elem.tagName === 'img') {
|
||||
const imgSrc = loadedCheerio(elem).attr('src');
|
||||
chapterText += `<img src="${imgSrc}">\n`;
|
||||
} else {
|
||||
const text = loadedCheerio(elem).text().trim();
|
||||
if (text === ' ') {
|
||||
chapterText += '<br>\n';
|
||||
} else {
|
||||
chapterText += `<p>${text}</p>\n`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return chapterText.trim();
|
||||
}
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const loadedCheerio = await this.fetchHTML(
|
||||
this.site + '/search/?kw=' + searchTerm,
|
||||
);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.rank-book-list')
|
||||
.find('a.search-book')
|
||||
.each((i, elem) => {
|
||||
const name = loadedCheerio(elem).find('.book-name').text().trim();
|
||||
const path = loadedCheerio(elem).attr('href') ?? '';
|
||||
const cover = (
|
||||
loadedCheerio(elem).find('img').attr('src') ?? ''
|
||||
).replace(/!min300jpg$/, '');
|
||||
|
||||
novels.push({ name, path, cover });
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new LinovelPlugin();
|
||||
@@ -0,0 +1,548 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchText } from '@/lib/fetch';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class Linovelib implements Plugin.PluginBase {
|
||||
id = 'linovelib';
|
||||
name = 'Linovelib';
|
||||
icon = 'src/cn/linovelib/icon.png';
|
||||
site = 'https://www.bilinovel.com';
|
||||
version = '1.1.3';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const rank = showLatestNovels ? 'lastupdate' : filters.rank.value;
|
||||
const url = `${this.site}/top/${rank}/${pageNo}.html`;
|
||||
|
||||
const body = await fetchText(url);
|
||||
if (body === '') throw Error('无法获取小说列表,请检查网络');
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.module-rank-booklist .book-layout').each((i, el) => {
|
||||
const url = loadedCheerio(el).attr('href');
|
||||
|
||||
const novelName = loadedCheerio(el).find('.book-title').text();
|
||||
const novelCover = loadedCheerio(el)
|
||||
.find('div.book-cover > img')
|
||||
.attr('data-src');
|
||||
if (!url) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: url,
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const url = this.site + novelPath;
|
||||
|
||||
const body = await fetchText(url);
|
||||
if (body === '') throw Error('无法获取小说内容,请检查网络');
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
chapters: [],
|
||||
name: loadedCheerio('#bookDetailWrapper .book-title').text(),
|
||||
};
|
||||
|
||||
novel.cover = loadedCheerio('#bookDetailWrapper img.book-cover').attr(
|
||||
'src',
|
||||
);
|
||||
|
||||
novel.summary = loadedCheerio('#bookSummary content').text();
|
||||
|
||||
novel.author = loadedCheerio('#bookDetailWrapper .book-rand-a a').text();
|
||||
|
||||
const meta = loadedCheerio('#bookDetailWrapper .book-meta').text();
|
||||
novel.status = meta.includes('完结')
|
||||
? NovelStatus.Completed
|
||||
: NovelStatus.Ongoing;
|
||||
|
||||
novel.genres = loadedCheerio('.tag-small.red')
|
||||
.children('a')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
// Table of Content is on a different page than the summary page
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
const idPattern = /\/(\d+)\.html/;
|
||||
const novelId = url.match(idPattern)?.[1];
|
||||
|
||||
const chaptersUrl = this.site + loadedCheerio('#btnReadBook').attr('href');
|
||||
const chaptersBody = await fetchText(chaptersUrl);
|
||||
|
||||
const chaptersLoadedCheerio = parseHTML(chaptersBody);
|
||||
|
||||
let volumeName: string, chapterId: number;
|
||||
|
||||
chaptersLoadedCheerio('#volumes .chapter-li:not(.volume-cover)').each(
|
||||
(i, el) => {
|
||||
if (chaptersLoadedCheerio(el).hasClass('chapter-bar')) {
|
||||
volumeName = chaptersLoadedCheerio(el).text();
|
||||
return;
|
||||
} else {
|
||||
const urlPart = chaptersLoadedCheerio(el)
|
||||
.find('.chapter-li-a')
|
||||
.attr('href');
|
||||
const chapterIdMatch = urlPart?.match(idPattern);
|
||||
|
||||
// Sometimes the href attribute does not contain the url, but javascript:cid(0).
|
||||
// Increment the previous chapter ID should result in the right URL
|
||||
if (chapterIdMatch) {
|
||||
chapterId = +chapterIdMatch[1];
|
||||
} else {
|
||||
chapterId++;
|
||||
}
|
||||
}
|
||||
|
||||
const chapterUrl = `/novel/${novelId}/${chapterId}.html`;
|
||||
const chapterName =
|
||||
volumeName +
|
||||
' — ' +
|
||||
chaptersLoadedCheerio(el).find('.chapter-index').text().trim();
|
||||
const releaseDate = null;
|
||||
|
||||
if (!chapterId) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
releaseTime: releaseDate,
|
||||
path: chapterUrl,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
novel.chapters = chapter;
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
let chapterName,
|
||||
chapterText = '',
|
||||
hasNextPage,
|
||||
pageHasNextPage,
|
||||
pageText = '';
|
||||
let pageNumber = 1;
|
||||
|
||||
/*
|
||||
* TODO: Maybe there are other ways to get the translation table
|
||||
* It is embed and encrypted inside readtool.js
|
||||
* UPDATE: Decrypted, see skillgg
|
||||
*/
|
||||
// const mapping_dict = {
|
||||
// '“': '「',
|
||||
// '’': '』',
|
||||
// '': '是',
|
||||
// '': '不',
|
||||
// '': '好',
|
||||
// '': '个',
|
||||
// '': '开',
|
||||
// '': '样',
|
||||
// '': '想',
|
||||
// '': '说',
|
||||
// '': '年',
|
||||
// '': '那',
|
||||
// '': '她',
|
||||
// '': '美',
|
||||
// '': '自',
|
||||
// '': '家',
|
||||
// '': '而',
|
||||
// '': '去',
|
||||
// '': '都',
|
||||
// '': '于',
|
||||
// '': '舔',
|
||||
// '': '他',
|
||||
// '': '只',
|
||||
// '': '看',
|
||||
// '': '来',
|
||||
// '': '用',
|
||||
// '': '道',
|
||||
// '': '得',
|
||||
// '': '乳',
|
||||
// '': '茎',
|
||||
// '': '肉',
|
||||
// '': '胸',
|
||||
// '': '淫',
|
||||
// '': '性',
|
||||
// '': '骚',
|
||||
// '”': '」',
|
||||
// '': '的',
|
||||
// '': '当',
|
||||
// '': '人',
|
||||
// '': '有',
|
||||
// '': '上',
|
||||
// '': '到',
|
||||
// '': '地',
|
||||
// '': '中',
|
||||
// '': '生',
|
||||
// '': '着',
|
||||
// '': '和',
|
||||
// '': '起',
|
||||
// '': '交',
|
||||
// '': '以',
|
||||
// '': '可',
|
||||
// '': '过',
|
||||
// '': '能',
|
||||
// '': '多',
|
||||
// '': '心',
|
||||
// '': '小',
|
||||
// '': '成',
|
||||
// '': '了',
|
||||
// '': '把',
|
||||
// '': '发',
|
||||
// '': '第',
|
||||
// '': '子',
|
||||
// '': '事',
|
||||
// '': '阴',
|
||||
// '': '欲',
|
||||
// '': '里',
|
||||
// '': '私',
|
||||
// '': '臀',
|
||||
// '': '脱',
|
||||
// '': '唇',
|
||||
// '‘': '『',
|
||||
// '': '一',
|
||||
// '': '我',
|
||||
// '': '在',
|
||||
// '': '这',
|
||||
// '': '们',
|
||||
// '': '时',
|
||||
// '': '为',
|
||||
// '': '你',
|
||||
// '': '国',
|
||||
// '': '就',
|
||||
// '': '要',
|
||||
// '': '也',
|
||||
// '': '后',
|
||||
// '': '没',
|
||||
// '': '下',
|
||||
// '': '天',
|
||||
// '': '对',
|
||||
// '': '然',
|
||||
// '': '学',
|
||||
// '': '之',
|
||||
// '': '出',
|
||||
// '': '没',
|
||||
// '': '如',
|
||||
// '': '还',
|
||||
// '': '大',
|
||||
// '': '作',
|
||||
// '': '种',
|
||||
// '': '液',
|
||||
// '': '呻',
|
||||
// '': '射',
|
||||
// '': '穴',
|
||||
// '': '么',
|
||||
// '': '裸',
|
||||
// };
|
||||
const skillgg: Record<string, string> = {
|
||||
'\u201c': '\u300c',
|
||||
'\u201d': '\u300d',
|
||||
'\u2018': '\u300e',
|
||||
'\u2019': '\u300f',
|
||||
'\ue82c': '\u7684',
|
||||
'\ue852': '\u4e00',
|
||||
'\ue82d': '\u662f',
|
||||
'\ue819': '\u4e86',
|
||||
'\ue856': '\u6211',
|
||||
'\ue857': '\u4e0d',
|
||||
'\ue816': '\u4eba',
|
||||
'\ue83c': '\u5728',
|
||||
'\ue830': '\u4ed6',
|
||||
'\ue82e': '\u6709',
|
||||
'\ue836': '\u8fd9',
|
||||
'\ue859': '\u4e2a',
|
||||
'\ue80a': '\u4e0a',
|
||||
'\ue855': '\u4eec',
|
||||
'\ue842': '\u6765',
|
||||
'\ue858': '\u5230',
|
||||
'\ue80b': '\u65f6',
|
||||
'\ue81f': '\u5927',
|
||||
'\ue84a': '\u5730',
|
||||
'\ue853': '\u4e3a',
|
||||
'\ue81e': '\u5b50',
|
||||
'\ue822': '\u4e2d',
|
||||
'\ue813': '\u4f60',
|
||||
'\ue85b': '\u8bf4',
|
||||
'\ue807': '\u751f',
|
||||
'\ue818': '\u56fd',
|
||||
'\ue810': '\u5e74',
|
||||
'\ue812': '\u7740',
|
||||
'\ue851': '\u5c31',
|
||||
'\ue801': '\u90a3',
|
||||
'\ue80c': '\u548c',
|
||||
'\ue815': '\u8981',
|
||||
'\ue84c': '\u5979',
|
||||
'\ue840': '\u51fa',
|
||||
'\ue848': '\u4e5f',
|
||||
'\ue835': '\u5f97',
|
||||
'\ue800': '\u91cc',
|
||||
'\ue826': '\u540e',
|
||||
'\ue863': '\u81ea',
|
||||
'\ue861': '\u4ee5',
|
||||
'\ue854': '\u4f1a',
|
||||
'\ue827': '\u5bb6',
|
||||
'\ue83b': '\u53ef',
|
||||
'\ue85d': '\u4e0b',
|
||||
'\ue84d': '\u800c',
|
||||
'\ue862': '\u8fc7',
|
||||
'\ue81c': '\u5929',
|
||||
'\ue81d': '\u53bb',
|
||||
'\ue860': '\u80fd',
|
||||
'\ue843': '\u5bf9',
|
||||
'\ue82f': '\u5c0f',
|
||||
'\ue802': '\u591a',
|
||||
'\ue831': '\u7136',
|
||||
'\ue84b': '\u4e8e',
|
||||
'\ue837': '\u5fc3',
|
||||
'\ue829': '\u5b66',
|
||||
'\ue85e': '\u4e48',
|
||||
'\ue83a': '\u4e4b',
|
||||
'\ue832': '\u90fd',
|
||||
'\ue808': '\u597d',
|
||||
'\ue841': '\u770b',
|
||||
'\ue821': '\u8d77',
|
||||
'\ue845': '\u53d1',
|
||||
'\ue803': '\u5f53',
|
||||
'\ue828': '\u6ca1',
|
||||
'\ue81b': '\u6210',
|
||||
'\ue83e': '\u53ea',
|
||||
'\ue820': '\u5982',
|
||||
'\ue84e': '\u4e8b',
|
||||
'\ue85a': '\u628a',
|
||||
'\ue806': '\u8fd8',
|
||||
'\ue83f': '\u7528',
|
||||
'\ue833': '\u7b2c',
|
||||
'\ue811': '\u6837',
|
||||
'\ue804': '\u9053',
|
||||
'\ue814': '\u60f3',
|
||||
'\ue80f': '\u4f5c',
|
||||
'\ue84f': '\u79cd',
|
||||
'\ue80e': '\u5f00',
|
||||
'\ue823': '\u7f8e',
|
||||
'\ue849': '\u4e73',
|
||||
'\ue805': '\u9634',
|
||||
'\ue809': '\u6db2',
|
||||
'\ue81a': '\u830e',
|
||||
'\ue844': '\u6b32',
|
||||
'\ue847': '\u547b',
|
||||
'\ue850': '\u8089',
|
||||
'\ue824': '\u4ea4',
|
||||
'\ue85f': '\u6027',
|
||||
'\ue817': '\u80f8',
|
||||
'\ue85c': '\u79c1',
|
||||
'\ue838': '\u7a74',
|
||||
'\ue82a': '\u6deb',
|
||||
'\ue83d': '\u81c0',
|
||||
'\ue82b': '\u8214',
|
||||
'\ue80d': '\u5c04',
|
||||
'\ue839': '\u8131',
|
||||
'\ue834': '\u88f8',
|
||||
'\ue846': '\u9a9a',
|
||||
'\ue825': '\u5507',
|
||||
};
|
||||
const addPage = async (pageCheerio: CheerioAPI) => {
|
||||
const formatPage = async () => {
|
||||
// Remove JS and notice of the website
|
||||
pageCheerio(
|
||||
'#acontent .adsbygoogle, #acontent script, center',
|
||||
).remove();
|
||||
|
||||
// Load lazyloaded images
|
||||
pageCheerio('#acontent img.imagecontent').each((i, el) => {
|
||||
// Sometimes images are either in data-src or src
|
||||
const imgSrc =
|
||||
pageCheerio(el).attr('data-src') || pageCheerio(el).attr('src');
|
||||
if (imgSrc) {
|
||||
// Clean up img element
|
||||
pageCheerio(el)
|
||||
.attr('src', imgSrc)
|
||||
.removeAttr('data-src')
|
||||
.removeClass('lazyload');
|
||||
}
|
||||
});
|
||||
|
||||
// Recover the original character
|
||||
pageText = pageCheerio('#acontent').html() || '';
|
||||
pageText = pageText.replace(/./g, char => skillgg[char] || char);
|
||||
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
await formatPage();
|
||||
chapterName =
|
||||
pageCheerio('#atitle + h3').text() +
|
||||
' — ' +
|
||||
pageCheerio('#atitle').text();
|
||||
if (chapterText === '') {
|
||||
chapterText = '<h2>' + chapterName + '</h2>';
|
||||
}
|
||||
chapterText += pageText;
|
||||
};
|
||||
|
||||
const loadPage = async (url: string) => {
|
||||
const headers = {
|
||||
'Accept':
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
'Accept-Language':
|
||||
'zh-CN,zh;q=0.9,zh-TW;q=0.8,zh-HK;q=0.7,en;q=0.6,en-GB;q=0.5,en-US;q=0.4',
|
||||
'Cache-Control': 'no-cache',
|
||||
};
|
||||
|
||||
const body = await fetchText(url, { headers });
|
||||
const pageCheerio = parseHTML(body);
|
||||
await addPage(pageCheerio);
|
||||
pageHasNextPage =
|
||||
pageCheerio('#footlink a:last').text() === '下一页' ||
|
||||
pageCheerio('#footlink a:last').text() === '下一頁'
|
||||
? true
|
||||
: false;
|
||||
return { pageCheerio, pageHasNextPage };
|
||||
};
|
||||
|
||||
let url = this.site + chapterPath;
|
||||
const baseUrl = url;
|
||||
do {
|
||||
const page = await loadPage(url);
|
||||
hasNextPage = page.pageHasNextPage;
|
||||
if (hasNextPage === true) {
|
||||
pageNumber++;
|
||||
url = baseUrl.replace(/\.html/gi, `_${pageNumber}` + '.html');
|
||||
}
|
||||
} while (hasNextPage === true);
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}/search/${encodeURI(searchTerm)}_${pageNo}.html`;
|
||||
|
||||
const body = await fetchText(url);
|
||||
if (body === '') throw Error('无法获取搜索结果,请检查网络');
|
||||
|
||||
const pageCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
// const addPage = async (pageCheerio: CheerioAPI, redirect: string) => {
|
||||
// const loadSearchResults = () => {
|
||||
// pageCheerio(".book-ol .book-layout").each((i, el) => {
|
||||
// let nUrl = pageCheerio(el).attr("href");
|
||||
|
||||
// const novelName = pageCheerio(el)
|
||||
// .find(".book-title")
|
||||
// .text();
|
||||
// const novelCover = pageCheerio(el)
|
||||
// .find("div.book-cover > img")
|
||||
// .attr("data-src");
|
||||
// const novelUrl = this.site + nUrl;
|
||||
|
||||
// if (!nUrl) return;
|
||||
|
||||
// novels.push({
|
||||
// name: novelName,
|
||||
// url: novelUrl,
|
||||
// cover: novelCover,
|
||||
// });
|
||||
// });
|
||||
// };
|
||||
|
||||
// const novelResults = pageCheerio(".book-ol a.book-layout");
|
||||
// if (novelResults.length === 0) {
|
||||
// } else {
|
||||
// loadSearchResults();
|
||||
// }
|
||||
|
||||
// if (redirect.length) {
|
||||
// novels.length = 0;
|
||||
// const novelName = pageCheerio(
|
||||
// "#bookDetailWrapper .book-title"
|
||||
// ).text();
|
||||
|
||||
// const novelCover = pageCheerio(
|
||||
// "#bookDetailWrapper div.book-cover > img"
|
||||
// ).attr("src");
|
||||
// const novelUrl =
|
||||
// this.site +
|
||||
// pageCheerio("#btnReadBook").attr("href")?.slice(0, -8) +
|
||||
// ".html";
|
||||
// novels.push({
|
||||
// name: novelName,
|
||||
// url: novelUrl,
|
||||
// cover: novelCover,
|
||||
// });
|
||||
// }
|
||||
// };
|
||||
|
||||
// NOTE: don't know redirect is for what, comment out for now
|
||||
|
||||
// const redirect = pageCheerio("div.book-layout").text();
|
||||
// await addPage(pageCheerio, redirect);
|
||||
|
||||
pageCheerio('.book-ol .book-layout').each((i, el) => {
|
||||
const nUrl = pageCheerio(el).attr('href');
|
||||
|
||||
const novelName = pageCheerio(el).find('.book-title').text();
|
||||
const novelCover = pageCheerio(el)
|
||||
.find('div.book-cover > img')
|
||||
.attr('data-src');
|
||||
|
||||
if (!nUrl) return;
|
||||
|
||||
novels.push({
|
||||
name: novelName,
|
||||
path: nUrl,
|
||||
cover: novelCover,
|
||||
});
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
rank: {
|
||||
label: '排行榜',
|
||||
value: 'monthvisit',
|
||||
options: [
|
||||
{ label: '月点击榜', value: 'monthvisit' },
|
||||
{ label: '周点击榜', value: 'weekvisit' },
|
||||
{ label: '月推荐榜', value: 'monthvote' },
|
||||
{ label: '周推荐榜', value: 'weekvote' },
|
||||
{ label: '月鲜花榜', value: 'monthflower' },
|
||||
{ label: '周鲜花榜', value: 'weekflower' },
|
||||
{ label: '月鸡蛋榜', value: 'monthegg' },
|
||||
{ label: '周鸡蛋榜', value: 'weekegg' },
|
||||
{ label: '最近更新', value: 'lastupdate' },
|
||||
{ label: '最新入库', value: 'postdate' },
|
||||
{ label: '收藏榜', value: 'goodnum' },
|
||||
{ label: '新书榜', value: 'newhot' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new Linovelib();
|
||||
@@ -0,0 +1,546 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchText } from '@/lib/fetch';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class Linovelib_tw implements Plugin.PluginBase {
|
||||
id = 'linovelib_tw';
|
||||
name = 'Linovelib(繁體)';
|
||||
icon = 'src/cn/linovelib/icon.png';
|
||||
site = 'https://tw.linovelib.com/';
|
||||
version = '1.0.0';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const rank = showLatestNovels ? 'lastupdate' : filters.rank.value;
|
||||
const url = `${this.site}/top/${rank}/${pageNo}.html`;
|
||||
|
||||
const body = await fetchText(url);
|
||||
if (body === '') throw Error('無法獲取小説列表, 請檢查網絡');
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.module-rank-booklist .book-layout').each((i, el) => {
|
||||
const url = loadedCheerio(el).attr('href');
|
||||
|
||||
const novelName = loadedCheerio(el).find('.book-title').text();
|
||||
const novelCover = loadedCheerio(el)
|
||||
.find('div.book-cover > img')
|
||||
.attr('data-src');
|
||||
if (!url) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: url,
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const url = this.site + novelPath;
|
||||
|
||||
const body = await fetchText(url);
|
||||
if (body === '') throw Error('無法獲取小説信息, 請檢查網絡');
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
chapters: [],
|
||||
name: loadedCheerio('#bookDetailWrapper .book-title').text(),
|
||||
};
|
||||
|
||||
novel.cover = loadedCheerio('#bookDetailWrapper img.book-cover').attr(
|
||||
'src',
|
||||
);
|
||||
|
||||
novel.summary = loadedCheerio('#bookSummary content').text();
|
||||
|
||||
novel.author = loadedCheerio('#bookDetailWrapper .book-rand-a a').text();
|
||||
|
||||
const meta = loadedCheerio('#bookDetailWrapper .book-meta').text();
|
||||
novel.status = meta.includes('完結')
|
||||
? NovelStatus.Completed
|
||||
: NovelStatus.Ongoing;
|
||||
|
||||
novel.genres = loadedCheerio('.tag-small.red')
|
||||
.children('a')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
// Table of Content is on a different page than the summary page
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
const idPattern = /\/(\d+)\.html/;
|
||||
const novelId = url.match(idPattern)?.[1];
|
||||
|
||||
const chaptersUrl = this.site + loadedCheerio('#btnReadBook').attr('href');
|
||||
const chaptersBody = await fetchText(chaptersUrl);
|
||||
|
||||
const chaptersLoadedCheerio = parseHTML(chaptersBody);
|
||||
|
||||
let volumeName: string, chapterId: number;
|
||||
|
||||
chaptersLoadedCheerio('#volumes .chapter-li:not(.volume-cover)').each(
|
||||
(i, el) => {
|
||||
if (chaptersLoadedCheerio(el).hasClass('chapter-bar')) {
|
||||
volumeName = chaptersLoadedCheerio(el).text();
|
||||
return;
|
||||
} else {
|
||||
const urlPart = chaptersLoadedCheerio(el)
|
||||
.find('.chapter-li-a')
|
||||
.attr('href');
|
||||
const chapterIdMatch = urlPart?.match(idPattern);
|
||||
|
||||
// Sometimes the href attribute does not contain the url, but javascript:cid(0).
|
||||
// Increment the previous chapter ID should result in the right URL
|
||||
if (chapterIdMatch) {
|
||||
chapterId = +chapterIdMatch[1];
|
||||
} else {
|
||||
chapterId++;
|
||||
}
|
||||
}
|
||||
|
||||
const chapterUrl = `/novel/${novelId}/${chapterId}.html`;
|
||||
const chapterName =
|
||||
volumeName +
|
||||
' — ' +
|
||||
chaptersLoadedCheerio(el).find('.chapter-index').text().trim();
|
||||
const releaseDate = null;
|
||||
|
||||
if (!chapterId) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
releaseTime: releaseDate,
|
||||
path: chapterUrl,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
novel.chapters = chapter;
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
let chapterName,
|
||||
chapterText = '',
|
||||
hasNextPage,
|
||||
pageHasNextPage,
|
||||
pageText = '';
|
||||
let pageNumber = 1;
|
||||
|
||||
/*
|
||||
* TODO: Maybe there are other ways to get the translation table
|
||||
* It is embed and encrypted inside readtool.js
|
||||
* UPDATE: Decrypted, see skillgg
|
||||
*/
|
||||
// const mapping_dict = {
|
||||
// '“': '「',
|
||||
// '’': '』',
|
||||
// '': '是',
|
||||
// '': '不',
|
||||
// '': '好',
|
||||
// '': '个',
|
||||
// '': '开',
|
||||
// '': '样',
|
||||
// '': '想',
|
||||
// '': '说',
|
||||
// '': '年',
|
||||
// '': '那',
|
||||
// '': '她',
|
||||
// '': '美',
|
||||
// '': '自',
|
||||
// '': '家',
|
||||
// '': '而',
|
||||
// '': '去',
|
||||
// '': '都',
|
||||
// '': '于',
|
||||
// '': '舔',
|
||||
// '': '他',
|
||||
// '': '只',
|
||||
// '': '看',
|
||||
// '': '来',
|
||||
// '': '用',
|
||||
// '': '道',
|
||||
// '': '得',
|
||||
// '': '乳',
|
||||
// '': '茎',
|
||||
// '': '肉',
|
||||
// '': '胸',
|
||||
// '': '淫',
|
||||
// '': '性',
|
||||
// '': '骚',
|
||||
// '”': '」',
|
||||
// '': '的',
|
||||
// '': '当',
|
||||
// '': '人',
|
||||
// '': '有',
|
||||
// '': '上',
|
||||
// '': '到',
|
||||
// '': '地',
|
||||
// '': '中',
|
||||
// '': '生',
|
||||
// '': '着',
|
||||
// '': '和',
|
||||
// '': '起',
|
||||
// '': '交',
|
||||
// '': '以',
|
||||
// '': '可',
|
||||
// '': '过',
|
||||
// '': '能',
|
||||
// '': '多',
|
||||
// '': '心',
|
||||
// '': '小',
|
||||
// '': '成',
|
||||
// '': '了',
|
||||
// '': '把',
|
||||
// '': '发',
|
||||
// '': '第',
|
||||
// '': '子',
|
||||
// '': '事',
|
||||
// '': '阴',
|
||||
// '': '欲',
|
||||
// '': '里',
|
||||
// '': '私',
|
||||
// '': '臀',
|
||||
// '': '脱',
|
||||
// '': '唇',
|
||||
// '‘': '『',
|
||||
// '': '一',
|
||||
// '': '我',
|
||||
// '': '在',
|
||||
// '': '这',
|
||||
// '': '们',
|
||||
// '': '时',
|
||||
// '': '为',
|
||||
// '': '你',
|
||||
// '': '国',
|
||||
// '': '就',
|
||||
// '': '要',
|
||||
// '': '也',
|
||||
// '': '后',
|
||||
// '': '没',
|
||||
// '': '下',
|
||||
// '': '天',
|
||||
// '': '对',
|
||||
// '': '然',
|
||||
// '': '学',
|
||||
// '': '之',
|
||||
// '': '出',
|
||||
// '': '没',
|
||||
// '': '如',
|
||||
// '': '还',
|
||||
// '': '大',
|
||||
// '': '作',
|
||||
// '': '种',
|
||||
// '': '液',
|
||||
// '': '呻',
|
||||
// '': '射',
|
||||
// '': '穴',
|
||||
// '': '么',
|
||||
// '': '裸',
|
||||
// };
|
||||
const skillgg: Record<string, string> = {
|
||||
'\u201c': '\u300c',
|
||||
'\u201d': '\u300d',
|
||||
'\u2018': '\u300e',
|
||||
'\u2019': '\u300f',
|
||||
'\ue82c': '\u7684',
|
||||
'\ue852': '\u4e00',
|
||||
'\ue82d': '\u662f',
|
||||
'\ue819': '\u4e86',
|
||||
'\ue856': '\u6211',
|
||||
'\ue857': '\u4e0d',
|
||||
'\ue816': '\u4eba',
|
||||
'\ue83c': '\u5728',
|
||||
'\ue830': '\u4ed6',
|
||||
'\ue82e': '\u6709',
|
||||
'\ue836': '\u8fd9',
|
||||
'\ue859': '\u4e2a',
|
||||
'\ue80a': '\u4e0a',
|
||||
'\ue855': '\u4eec',
|
||||
'\ue842': '\u6765',
|
||||
'\ue858': '\u5230',
|
||||
'\ue80b': '\u65f6',
|
||||
'\ue81f': '\u5927',
|
||||
'\ue84a': '\u5730',
|
||||
'\ue853': '\u4e3a',
|
||||
'\ue81e': '\u5b50',
|
||||
'\ue822': '\u4e2d',
|
||||
'\ue813': '\u4f60',
|
||||
'\ue85b': '\u8bf4',
|
||||
'\ue807': '\u751f',
|
||||
'\ue818': '\u56fd',
|
||||
'\ue810': '\u5e74',
|
||||
'\ue812': '\u7740',
|
||||
'\ue851': '\u5c31',
|
||||
'\ue801': '\u90a3',
|
||||
'\ue80c': '\u548c',
|
||||
'\ue815': '\u8981',
|
||||
'\ue84c': '\u5979',
|
||||
'\ue840': '\u51fa',
|
||||
'\ue848': '\u4e5f',
|
||||
'\ue835': '\u5f97',
|
||||
'\ue800': '\u91cc',
|
||||
'\ue826': '\u540e',
|
||||
'\ue863': '\u81ea',
|
||||
'\ue861': '\u4ee5',
|
||||
'\ue854': '\u4f1a',
|
||||
'\ue827': '\u5bb6',
|
||||
'\ue83b': '\u53ef',
|
||||
'\ue85d': '\u4e0b',
|
||||
'\ue84d': '\u800c',
|
||||
'\ue862': '\u8fc7',
|
||||
'\ue81c': '\u5929',
|
||||
'\ue81d': '\u53bb',
|
||||
'\ue860': '\u80fd',
|
||||
'\ue843': '\u5bf9',
|
||||
'\ue82f': '\u5c0f',
|
||||
'\ue802': '\u591a',
|
||||
'\ue831': '\u7136',
|
||||
'\ue84b': '\u4e8e',
|
||||
'\ue837': '\u5fc3',
|
||||
'\ue829': '\u5b66',
|
||||
'\ue85e': '\u4e48',
|
||||
'\ue83a': '\u4e4b',
|
||||
'\ue832': '\u90fd',
|
||||
'\ue808': '\u597d',
|
||||
'\ue841': '\u770b',
|
||||
'\ue821': '\u8d77',
|
||||
'\ue845': '\u53d1',
|
||||
'\ue803': '\u5f53',
|
||||
'\ue828': '\u6ca1',
|
||||
'\ue81b': '\u6210',
|
||||
'\ue83e': '\u53ea',
|
||||
'\ue820': '\u5982',
|
||||
'\ue84e': '\u4e8b',
|
||||
'\ue85a': '\u628a',
|
||||
'\ue806': '\u8fd8',
|
||||
'\ue83f': '\u7528',
|
||||
'\ue833': '\u7b2c',
|
||||
'\ue811': '\u6837',
|
||||
'\ue804': '\u9053',
|
||||
'\ue814': '\u60f3',
|
||||
'\ue80f': '\u4f5c',
|
||||
'\ue84f': '\u79cd',
|
||||
'\ue80e': '\u5f00',
|
||||
'\ue823': '\u7f8e',
|
||||
'\ue849': '\u4e73',
|
||||
'\ue805': '\u9634',
|
||||
'\ue809': '\u6db2',
|
||||
'\ue81a': '\u830e',
|
||||
'\ue844': '\u6b32',
|
||||
'\ue847': '\u547b',
|
||||
'\ue850': '\u8089',
|
||||
'\ue824': '\u4ea4',
|
||||
'\ue85f': '\u6027',
|
||||
'\ue817': '\u80f8',
|
||||
'\ue85c': '\u79c1',
|
||||
'\ue838': '\u7a74',
|
||||
'\ue82a': '\u6deb',
|
||||
'\ue83d': '\u81c0',
|
||||
'\ue82b': '\u8214',
|
||||
'\ue80d': '\u5c04',
|
||||
'\ue839': '\u8131',
|
||||
'\ue834': '\u88f8',
|
||||
'\ue846': '\u9a9a',
|
||||
'\ue825': '\u5507',
|
||||
};
|
||||
const addPage = async (pageCheerio: CheerioAPI) => {
|
||||
const formatPage = async () => {
|
||||
// Remove JS and notice of the website
|
||||
pageCheerio('div.cgo, center').remove();
|
||||
|
||||
// Load lazyloaded images
|
||||
pageCheerio('#acontentl img.imagecontent').each((i, el) => {
|
||||
// Sometimes images are either in data-src or src
|
||||
const imgSrc =
|
||||
pageCheerio(el).attr('data-src') || pageCheerio(el).attr('src');
|
||||
if (imgSrc) {
|
||||
// Clean up img element
|
||||
pageCheerio(el)
|
||||
.attr('src', imgSrc)
|
||||
.removeAttr('data-src')
|
||||
.removeClass('lazyload');
|
||||
}
|
||||
});
|
||||
|
||||
// Recover the original character
|
||||
pageText = pageCheerio('#acontentl').html() || '';
|
||||
pageText = pageText.replace(/./g, char => skillgg[char] || char);
|
||||
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
await formatPage();
|
||||
chapterName =
|
||||
pageCheerio('#atitle + h3').text() +
|
||||
' — ' +
|
||||
pageCheerio('#atitle').text();
|
||||
if (chapterText === '') {
|
||||
chapterText = '<h2>' + chapterName + '</h2>';
|
||||
}
|
||||
chapterText += pageText;
|
||||
};
|
||||
|
||||
const loadPage = async (url: string) => {
|
||||
const headers = {
|
||||
'Accept':
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
'Accept-Language':
|
||||
'zh-CN,zh;q=0.9,zh-TW;q=0.8,zh-HK;q=0.7,en;q=0.6,en-GB;q=0.5,en-US;q=0.4',
|
||||
'Cache-Control': 'no-cache',
|
||||
};
|
||||
|
||||
const body = await fetchText(url, { headers });
|
||||
const pageCheerio = parseHTML(body);
|
||||
await addPage(pageCheerio);
|
||||
pageHasNextPage =
|
||||
pageCheerio('#footlink a:last').text() === '下一页' ||
|
||||
pageCheerio('#footlink a:last').text() === '下一頁'
|
||||
? true
|
||||
: false;
|
||||
return { pageCheerio, pageHasNextPage };
|
||||
};
|
||||
|
||||
let url = this.site + chapterPath;
|
||||
const baseUrl = url;
|
||||
do {
|
||||
const page = await loadPage(url);
|
||||
hasNextPage = page.pageHasNextPage;
|
||||
if (hasNextPage === true) {
|
||||
pageNumber++;
|
||||
url = baseUrl.replace(/\.html/gi, `_${pageNumber}` + '.html');
|
||||
}
|
||||
} while (hasNextPage === true);
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}/search/${encodeURI(searchTerm)}_${pageNo}.html`;
|
||||
|
||||
const body = await fetchText(url);
|
||||
if (body === '') throw Error('無法獲取搜索結果, 請檢查網絡');
|
||||
|
||||
const pageCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
// const addPage = async (pageCheerio: CheerioAPI, redirect: string) => {
|
||||
// const loadSearchResults = () => {
|
||||
// pageCheerio(".book-ol .book-layout").each((i, el) => {
|
||||
// let nUrl = pageCheerio(el).attr("href");
|
||||
|
||||
// const novelName = pageCheerio(el)
|
||||
// .find(".book-title")
|
||||
// .text();
|
||||
// const novelCover = pageCheerio(el)
|
||||
// .find("div.book-cover > img")
|
||||
// .attr("data-src");
|
||||
// const novelUrl = this.site + nUrl;
|
||||
|
||||
// if (!nUrl) return;
|
||||
|
||||
// novels.push({
|
||||
// name: novelName,
|
||||
// url: novelUrl,
|
||||
// cover: novelCover,
|
||||
// });
|
||||
// });
|
||||
// };
|
||||
|
||||
// const novelResults = pageCheerio(".book-ol a.book-layout");
|
||||
// if (novelResults.length === 0) {
|
||||
// } else {
|
||||
// loadSearchResults();
|
||||
// }
|
||||
|
||||
// if (redirect.length) {
|
||||
// novels.length = 0;
|
||||
// const novelName = pageCheerio(
|
||||
// "#bookDetailWrapper .book-title"
|
||||
// ).text();
|
||||
|
||||
// const novelCover = pageCheerio(
|
||||
// "#bookDetailWrapper div.book-cover > img"
|
||||
// ).attr("src");
|
||||
// const novelUrl =
|
||||
// this.site +
|
||||
// pageCheerio("#btnReadBook").attr("href")?.slice(0, -8) +
|
||||
// ".html";
|
||||
// novels.push({
|
||||
// name: novelName,
|
||||
// url: novelUrl,
|
||||
// cover: novelCover,
|
||||
// });
|
||||
// }
|
||||
// };
|
||||
|
||||
// NOTE: don't know redirect is for what, comment out for now
|
||||
|
||||
// const redirect = pageCheerio("div.book-layout").text();
|
||||
// await addPage(pageCheerio, redirect);
|
||||
|
||||
pageCheerio('.book-ol .book-layout').each((i, el) => {
|
||||
const nUrl = pageCheerio(el).attr('href');
|
||||
|
||||
const novelName = pageCheerio(el).find('.book-title').text();
|
||||
const novelCover = pageCheerio(el)
|
||||
.find('div.book-cover > img')
|
||||
.attr('data-src');
|
||||
|
||||
if (!nUrl) return;
|
||||
|
||||
novels.push({
|
||||
name: novelName,
|
||||
path: nUrl,
|
||||
cover: novelCover,
|
||||
});
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
rank: {
|
||||
label: '排行榜',
|
||||
value: 'monthvisit',
|
||||
options: [
|
||||
{ label: '月點擊榜', value: 'monthvisit' },
|
||||
{ label: '周點擊榜', value: 'weekvisit' },
|
||||
{ label: '月推薦榜', value: 'monthvote' },
|
||||
{ label: '周推薦榜', value: 'weekvote' },
|
||||
{ label: '月鮮花榜', value: 'monthflower' },
|
||||
{ label: '周鮮花榜', value: 'weekflower' },
|
||||
{ label: '月鷄蛋榜', value: 'monthegg' },
|
||||
{ label: '周鷄蛋榜', value: 'weekegg' },
|
||||
{ label: '最近更新', value: 'lastupdate' },
|
||||
{ label: '最新入庫', value: 'postdate' },
|
||||
{ label: '收藏榜', value: 'goodnum' },
|
||||
{ label: '新書榜', value: 'newhot' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new Linovelib_tw();
|
||||
@@ -0,0 +1,328 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@/lib/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
const makeAbsolute = (
|
||||
relativeUrl: string | undefined,
|
||||
baseUrl: string,
|
||||
): string | undefined => {
|
||||
if (!relativeUrl) return undefined;
|
||||
try {
|
||||
if (relativeUrl.startsWith('//')) {
|
||||
return new URL(baseUrl).protocol + relativeUrl;
|
||||
}
|
||||
if (
|
||||
relativeUrl.startsWith('http://') ||
|
||||
relativeUrl.startsWith('https://')
|
||||
) {
|
||||
return relativeUrl;
|
||||
}
|
||||
return new URL(relativeUrl, baseUrl).href;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
class Novel543Plugin implements Plugin.PluginBase {
|
||||
id = 'novel543';
|
||||
name = 'Novel543';
|
||||
site = 'https://www.novel543.com/';
|
||||
version = '1.0.0';
|
||||
icon = 'src/cn/novel543/icon.png';
|
||||
|
||||
imageRequestInit = {
|
||||
headers: {
|
||||
Referer: this.site,
|
||||
},
|
||||
};
|
||||
|
||||
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo > 1) return [];
|
||||
|
||||
const result = await fetchApi(this.site);
|
||||
if (!result.ok) return [];
|
||||
|
||||
const $ = parseHTML(await result.text());
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
const processedPaths = new Set<string>();
|
||||
|
||||
$('ul.list > li.media, ul.list li > a[href^="/"][href$="/"]').each(
|
||||
(_i, el) => {
|
||||
const $el = $(el);
|
||||
let novelPath: string | undefined;
|
||||
let novelName: string | undefined;
|
||||
let novelCover: string | undefined;
|
||||
|
||||
if ($el.is('li.media')) {
|
||||
const $link = $el.find('.media-content h3 a');
|
||||
novelPath = $link.attr('href')?.trim();
|
||||
novelName = $link.text().trim();
|
||||
novelCover = $el.find('.media-left img').attr('src')?.trim();
|
||||
} else if ($el.is('a')) {
|
||||
novelPath = $el.attr('href')?.trim();
|
||||
novelName =
|
||||
$el.find('h3, b, span').first().text().trim() ||
|
||||
$el.parent().find('h3').text().trim() ||
|
||||
$el.text().trim();
|
||||
novelCover =
|
||||
$el.find('img').attr('src')?.trim() ||
|
||||
$el.parent().find('img').attr('src')?.trim();
|
||||
}
|
||||
|
||||
if (
|
||||
novelPath &&
|
||||
novelName &&
|
||||
novelPath.match(/^\/\d+\/$/) &&
|
||||
!processedPaths.has(novelPath)
|
||||
) {
|
||||
novels.push({
|
||||
name: novelName,
|
||||
path: novelPath,
|
||||
cover: makeAbsolute(novelCover, this.site) || defaultCover,
|
||||
});
|
||||
processedPaths.add(novelPath);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novelUrl = makeAbsolute(novelPath, this.site);
|
||||
if (!novelUrl) throw new Error('Invalid novel URL');
|
||||
|
||||
const result = await fetchApi(novelUrl);
|
||||
if (!result.ok) throw new Error('Failed to fetch novel');
|
||||
|
||||
const $ = parseHTML(await result.text());
|
||||
const $infoSection = $('section#detail div.media-content.info');
|
||||
const $modSection = $('section#detail div.mod');
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: $infoSection.find('h1.title').text().trim() || 'Untitled',
|
||||
cover:
|
||||
makeAbsolute(
|
||||
$('section#detail div.cover img').attr('src'),
|
||||
this.site,
|
||||
) || defaultCover,
|
||||
summary: $modSection.find('div.intro').text().trim() || undefined,
|
||||
author:
|
||||
$infoSection.find('p.meta span.author').text().trim() || undefined,
|
||||
genres:
|
||||
$infoSection
|
||||
.find('p.meta a[href*="/bookstack/"]')
|
||||
.map((_i, el) => $(el).text().trim())
|
||||
.get()
|
||||
.join(', ') || undefined,
|
||||
status: NovelStatus.Unknown,
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
const chapterListPath =
|
||||
$modSection
|
||||
.find('p.action.buttons a.button.is-info[href$="/dir"]')
|
||||
.attr('href') ||
|
||||
$infoSection.find('a.button.is-info[href$="/dir"]').attr('href');
|
||||
|
||||
if (chapterListPath) {
|
||||
novel.chapters = await this.parseChapterList(chapterListPath);
|
||||
}
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapterList(
|
||||
chapterListPath: string,
|
||||
): Promise<Plugin.ChapterItem[]> {
|
||||
const chapterListUrl = makeAbsolute(chapterListPath, this.site);
|
||||
if (!chapterListUrl) return [];
|
||||
|
||||
const result = await fetchApi(chapterListUrl);
|
||||
if (!result.ok) return [];
|
||||
|
||||
const $ = parseHTML(await result.text());
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
$('div.chaplist ul.all li a').each((index, el) => {
|
||||
const $el = $(el);
|
||||
const chapterName = $el.text().trim();
|
||||
const chapterUrl = $el.attr('href')?.trim();
|
||||
|
||||
if (chapterName && chapterUrl) {
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl,
|
||||
chapterNumber: index + 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const sortButtonText = $('div.chaplist .header button.reverse span')
|
||||
.last()
|
||||
.text()
|
||||
.trim();
|
||||
if (sortButtonText === '倒序') {
|
||||
chapters.reverse();
|
||||
chapters.forEach((chap, index) => (chap.chapterNumber = index + 1));
|
||||
}
|
||||
|
||||
return chapters;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const chapterUrl = makeAbsolute(chapterPath, this.site);
|
||||
if (!chapterUrl) throw new Error('Invalid chapter URL');
|
||||
|
||||
const result = await fetchApi(chapterUrl);
|
||||
if (!result.ok) throw new Error('Failed to fetch chapter');
|
||||
|
||||
const $ = parseHTML(await result.text());
|
||||
const $content = $('div.content.py-5');
|
||||
if (!$content.length) return 'Error: Could not find chapter content';
|
||||
|
||||
$content
|
||||
.find(
|
||||
'script, style, ins, iframe, [class*="ads"], [id*="ads"], [class*="google"], [id*="google"], [class*="recommend"], div[align="center"], p:contains("推薦本書"), a[href*="javascript:"]',
|
||||
)
|
||||
.remove();
|
||||
|
||||
$content.find('p').each((_i, el) => {
|
||||
const $p = $(el);
|
||||
const pText = $p.text().trim();
|
||||
if (
|
||||
pText.includes('請記住本站域名') ||
|
||||
pText.includes('手機版閱讀網址') ||
|
||||
pText.includes('novel543') ||
|
||||
pText.includes('稷下書院') ||
|
||||
pText.includes('最快更新') ||
|
||||
pText.includes('最新章節') ||
|
||||
pText.includes('章節報錯') ||
|
||||
pText.match(/app|APP|下載|客户端|关注微信|公众号/i) ||
|
||||
pText.length === 0 ||
|
||||
($p
|
||||
.html()
|
||||
?.replace(/ /g, '')
|
||||
.trim() === '' &&
|
||||
$p.find('img').length === 0) ||
|
||||
pText.includes('溫馨提示')
|
||||
) {
|
||||
$p.remove();
|
||||
}
|
||||
});
|
||||
|
||||
$content
|
||||
.contents()
|
||||
.filter(function () {
|
||||
return this.type === 'comment';
|
||||
})
|
||||
.remove();
|
||||
|
||||
let chapterText = $content.html();
|
||||
if (!chapterText) return 'Error: Chapter content was empty';
|
||||
|
||||
chapterText = chapterText
|
||||
.replace(/<\s*p[^>]*>/gi, '\n\n')
|
||||
.replace(/<\s*br[^>]*>/gi, '\n');
|
||||
|
||||
chapterText = parseHTML(`<div>${chapterText}</div>`).text();
|
||||
|
||||
return chapterText
|
||||
.replace(/[\t ]+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo > 1) return [];
|
||||
|
||||
if (/^\d+$/.test(searchTerm)) {
|
||||
try {
|
||||
const novelPath = `/${searchTerm}/`;
|
||||
const novel = await this.parseNovel(novelPath);
|
||||
return [
|
||||
{
|
||||
name: novel.name,
|
||||
path: novelPath,
|
||||
cover: novel.cover,
|
||||
},
|
||||
];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const searchUrl = `${this.site}search/${encodeURIComponent(searchTerm)}`;
|
||||
let body = '';
|
||||
|
||||
try {
|
||||
const result = await fetchApi(searchUrl);
|
||||
if (!result.ok) {
|
||||
if (result.status === 403 || result.status === 503) {
|
||||
throw new Error(
|
||||
'Cloudflare protection detected (HTTP error). Please try opening the plugin in WebView first to solve the challenge.',
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
body = await result.text();
|
||||
const $ = parseHTML(body);
|
||||
const pageTitle = $('title').text().toLowerCase();
|
||||
|
||||
// Check for various Cloudflare challenge indicators
|
||||
if (
|
||||
pageTitle.includes('attention required') ||
|
||||
pageTitle.includes('just a moment') ||
|
||||
pageTitle.includes('please wait') ||
|
||||
pageTitle.includes('verifying') ||
|
||||
body.includes('Verifying you are human') ||
|
||||
body.includes('cf-browser-verification') ||
|
||||
body.includes('cf_captcha_container')
|
||||
) {
|
||||
throw new Error(
|
||||
'Cloudflare protection detected. Please try opening the plugin in WebView first to solve the challenge.',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
// If it's already our custom error, re-throw it
|
||||
if (error.message.includes('Cloudflare protection detected')) {
|
||||
throw error;
|
||||
}
|
||||
// For other errors, throw a generic error
|
||||
throw new Error(`Failed to fetch search results: ${error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const $ = parseHTML(body);
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
$('div.search-list ul.list > li.media').each((_i, el) => {
|
||||
const $el = $(el);
|
||||
const $link = $el.find('.media-content h3 a');
|
||||
const novelPath = $link.attr('href')?.trim();
|
||||
const novelName = $link.text().trim();
|
||||
const novelCover = $el.find('.media-left img').attr('src')?.trim();
|
||||
|
||||
if (novelPath && novelName && novelPath.match(/^\/\d+\/$/)) {
|
||||
novels.push({
|
||||
name: novelName,
|
||||
path: novelPath,
|
||||
cover: makeAbsolute(novelCover, this.site) || defaultCover,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new Novel543Plugin();
|
||||
@@ -0,0 +1,201 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
|
||||
class NovelsOnline implements Plugin.PluginBase {
|
||||
id = 'NO.net';
|
||||
name = 'novelsOnline';
|
||||
site = 'https://novelsonline.net';
|
||||
icon = 'src/en/novelsonline/icon.png';
|
||||
version = '1.0.0';
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = this.site;
|
||||
if (filters.genre.value !== '') link += `/category/${filters.genre.value}/`;
|
||||
else link += `/top-novel/`;
|
||||
|
||||
link += page;
|
||||
link += `?change_type=${filters.sort.value}`;
|
||||
|
||||
const body = await fetchApi(link).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const novel: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.top-novel-block').each((i, el) => {
|
||||
const novelName = loadedCheerio(el).find('h2').text();
|
||||
const novelCover = loadedCheerio(el)
|
||||
.find('.top-novel-cover img')
|
||||
.attr('src');
|
||||
const novelUrl = loadedCheerio(el).find('h2 a').attr('href');
|
||||
if (!novelUrl) return;
|
||||
|
||||
novel.push({
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
});
|
||||
});
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const result = await fetchApi(this.site + novelPath).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
const $ = parseHTML(result);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: $('h1').text() || 'Untitled',
|
||||
cover: $('.novel-cover').find('a > img').attr('src'),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
$('.novel-detail-item').each((i, el) => {
|
||||
const detailName = $(el).find('h6').text();
|
||||
const detail = $(el).find('.novel-detail-body');
|
||||
|
||||
switch (detailName) {
|
||||
case 'Description':
|
||||
novel.summary = detail.text();
|
||||
break;
|
||||
case 'Genre':
|
||||
novel.genres = detail
|
||||
.find('li')
|
||||
.map((_, el) => $(el).text())
|
||||
.get()
|
||||
.join(', ');
|
||||
break;
|
||||
case 'Author(s)':
|
||||
novel.author = detail
|
||||
.find('li')
|
||||
.map((_, el) => $(el).text())
|
||||
.get()
|
||||
.join(', ');
|
||||
break;
|
||||
case 'Status':
|
||||
novel.status = detail.text().trim();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
novel.chapters = $('ul.chapter-chs > li > a')
|
||||
.map((_, el) => {
|
||||
const chapterUrl = $(el).attr('href');
|
||||
const chapterName = $(el).text();
|
||||
|
||||
return {
|
||||
name: chapterName,
|
||||
path: chapterUrl?.replace(this.site, ''),
|
||||
} as Plugin.ChapterItem;
|
||||
})
|
||||
.get();
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(this.site + chapterPath).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
const loadedCheerio = parseHTML(result);
|
||||
|
||||
const chapterText = loadedCheerio('#contentall').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const result = await fetchApi(this.site + '/sResults.php', {
|
||||
headers: {
|
||||
Accept: '*/*',
|
||||
'Accept-Language': 'pl,en-US;q=0.7,en;q=0.3',
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
},
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ q: searchTerm }).toString(),
|
||||
}).then(res => res.text());
|
||||
|
||||
const $ = parseHTML(result);
|
||||
|
||||
const headers = $('li');
|
||||
return headers
|
||||
.map((i, h) => {
|
||||
const novelName = $(h).text();
|
||||
const novelUrl = $(h).find('a').attr('href');
|
||||
const novelCover = $(h).find('img').attr('src');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
return {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
})
|
||||
.get()
|
||||
.filter(sr => sr !== null);
|
||||
}
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
value: 'top_rated',
|
||||
label: 'Sort by',
|
||||
options: [
|
||||
{ label: 'Top Rated', value: 'top_rated' },
|
||||
{ label: 'Most Viewed', value: 'view' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genre: {
|
||||
value: '',
|
||||
label: 'Category',
|
||||
options: [
|
||||
{ label: 'None', value: '' },
|
||||
{ label: 'Action', value: 'action' },
|
||||
{ label: 'Adventure', value: 'adventure' },
|
||||
{ label: 'Celebrity', value: 'celebrity' },
|
||||
{ label: 'Comedy', value: 'comedy' },
|
||||
{ label: 'Drama', value: 'drama' },
|
||||
{ label: 'Ecchi', value: 'ecchi' },
|
||||
{ label: 'Fantasy', value: 'fantasy' },
|
||||
{ label: 'Gender Bender', value: 'gender-bender' },
|
||||
{ label: 'Harem', value: 'harem' },
|
||||
{ label: 'Historical', value: 'historical' },
|
||||
{ label: 'Horror', value: 'horror' },
|
||||
{ label: 'Josei', value: 'josei' },
|
||||
{ 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: 'Shotacon', value: 'shotacon' },
|
||||
{ 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: 'Sports', value: 'sports' },
|
||||
{ label: 'Supernatural', value: 'supernatural' },
|
||||
{ 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.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new NovelsOnline();
|
||||
@@ -0,0 +1,223 @@
|
||||
import { CheerioAPI, load } from 'cheerio';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
|
||||
class StorySeedlingPlugin implements Plugin.PluginBase {
|
||||
id = 'storyseedling';
|
||||
name = 'StorySeedling';
|
||||
icon = 'src/en/storyseedling/icon.png';
|
||||
site = 'https://storyseedling.com/';
|
||||
version = '1.0.3';
|
||||
|
||||
async getCheerio(url: string, search: boolean): Promise<CheerioAPI> {
|
||||
const r = await fetchApi(url);
|
||||
if (!r.ok && search != true)
|
||||
throw new Error(
|
||||
'Could not reach site (' + r.status + ') try to open in webview.',
|
||||
);
|
||||
const $ = load(await r.text());
|
||||
|
||||
return $;
|
||||
}
|
||||
|
||||
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
const body = await fetchApi(this.site + 'browse').then(r => r.text());
|
||||
const loadedCheerio = load(body);
|
||||
|
||||
const postValue = loadedCheerio('div[ax-load][x-data]')
|
||||
.attr('x-data')
|
||||
?.replace("browse('", '')
|
||||
.replace("')", '') as string;
|
||||
|
||||
const data = new FormData();
|
||||
data.append('search', '');
|
||||
data.append('orderBy', 'recent');
|
||||
data.append('curpage', pageNo.toString());
|
||||
data.append('post', postValue);
|
||||
data.append('action', 'fetch_browse');
|
||||
|
||||
const response: any = await fetchApi(this.site + 'ajax', {
|
||||
body: data,
|
||||
method: 'POST',
|
||||
}).then(res => res.json());
|
||||
|
||||
response.data.posts.forEach((element: any) =>
|
||||
novels.push({
|
||||
name: element.title,
|
||||
cover: element.thumbnail,
|
||||
path: element.permalink.replace(this.site, ''),
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const $ = await this.getCheerio(this.site + novelPath, false);
|
||||
const baseUrl = this.site;
|
||||
|
||||
const novel: Partial<Plugin.SourceNovel> = {
|
||||
path: novelPath,
|
||||
};
|
||||
|
||||
novel.name = $('h1').text().trim();
|
||||
const coverUrl = $('img[x-ref="art"].w-full.rounded.shadow-md').attr('src');
|
||||
|
||||
if (coverUrl) {
|
||||
novel.cover = new URL(coverUrl, baseUrl).href;
|
||||
}
|
||||
|
||||
const genres: string[] = [];
|
||||
$(
|
||||
'section[x-data="{ tab: location.hash.substr(1) || \'chapters\' }"].relative > div > div > div.flex.flex-wrap > a',
|
||||
).each(function () {
|
||||
genres.push($(this).text().trim());
|
||||
});
|
||||
novel.genres = genres.join(', ');
|
||||
|
||||
novel.author = $('div.mb-1 a').text().trim();
|
||||
|
||||
const rawStatus = $('div.gap-2 span.text-sm').text().trim();
|
||||
const map: Record<string, string> = {
|
||||
ongoing: NovelStatus.Ongoing,
|
||||
hiatus: NovelStatus.OnHiatus,
|
||||
dropped: NovelStatus.Cancelled,
|
||||
cancelled: NovelStatus.Cancelled,
|
||||
completed: NovelStatus.Completed,
|
||||
};
|
||||
novel.status = map[rawStatus.toLowerCase()] ?? NovelStatus.Unknown;
|
||||
|
||||
const summaryDiv = $('div.mb-4.order-2:not(.lg\\:grid-in-buttons)');
|
||||
const pTagSummary = summaryDiv.find('p');
|
||||
novel.summary =
|
||||
pTagSummary.length > 0
|
||||
? pTagSummary
|
||||
.map((_, el) => $(el).text().trim())
|
||||
.get()
|
||||
.join('\n\n')
|
||||
: summaryDiv.text().trim(); // --- Else (no <p> tags) ---
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
const xdata = $('.bg-accent div[ax-load][x-data]').attr('x-data');
|
||||
// expected data format: toc('000000', 'xxxxxxxxxx') (6 numbers, 10 hex)
|
||||
if (xdata) {
|
||||
const listXdata = xdata?.split("'");
|
||||
const dataNovelId = listXdata[1];
|
||||
const dataNovelN = listXdata[3];
|
||||
const idMatch = novelPath.match(/\d+/); // Error checking in case of extraneous slashes
|
||||
const novelId = dataNovelId || (idMatch ? idMatch[0] : null);
|
||||
|
||||
if (novelId) {
|
||||
const chapterListing = 'ajax'; // Currently URL is only ajax
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('post', dataNovelN);
|
||||
formData.append('id', dataNovelId);
|
||||
formData.append('action', 'series_toc');
|
||||
|
||||
const chaptersUrl = `${this.site}${chapterListing}`;
|
||||
const refererUrl = `${this.site}${novel.path}`;
|
||||
|
||||
let results = await fetchApi(chaptersUrl, {
|
||||
method: 'POST',
|
||||
referrer: refererUrl,
|
||||
referrerPolicy: 'origin',
|
||||
body: formData,
|
||||
})
|
||||
.then(r => r.json())
|
||||
.catch(
|
||||
e =>
|
||||
(novel.summary =
|
||||
'Chapter Parse Error: ' + e + '\n\n' + novel.summary),
|
||||
);
|
||||
|
||||
if (results.data) {
|
||||
results = results.data;
|
||||
results.forEach(function (chap: any) {
|
||||
if (chap.url == null) {
|
||||
return;
|
||||
}
|
||||
const name = chap.title;
|
||||
const url = chap.url as string;
|
||||
const releaseTime = chap.date;
|
||||
const chapterNumber = chap.slug;
|
||||
|
||||
chapters.push({
|
||||
name: name,
|
||||
path: url.replace(baseUrl, ''),
|
||||
releaseTime,
|
||||
chapterNumber: parseInt(chapterNumber),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
novel.chapters = chapters;
|
||||
|
||||
return novel as Plugin.SourceNovel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const $ = await this.getCheerio(this.site + chapterPath, false);
|
||||
|
||||
const xdata = $('div[ax-load][x-data]').attr('x-data');
|
||||
|
||||
const t = $('div.justify-center > div.mb-4');
|
||||
let chapterText = t.html() || '';
|
||||
|
||||
if (xdata) {
|
||||
chapterText =
|
||||
chapterText +
|
||||
"\n\n Error parsing chapter: Turnstile detected. Advise just reading in web view until there's a fix.";
|
||||
// const listXdata = xdata?.split("'");
|
||||
// const dataNovelId = listXdata[1];
|
||||
// const dataNovelN = listXdata[3];
|
||||
}
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
const body = await fetchApi(this.site + 'browse').then(r => r.text());
|
||||
const loadedCheerio = load(body);
|
||||
|
||||
const postValue = loadedCheerio('div[ax-load][x-data]')
|
||||
.attr('x-data')
|
||||
?.replace("browse('", '')
|
||||
.replace("')", '') as string;
|
||||
|
||||
const data = new FormData();
|
||||
data.append('search', searchTerm);
|
||||
data.append('orderBy', 'recent');
|
||||
data.append('curpage', pageNo.toString());
|
||||
data.append('post', postValue);
|
||||
data.append('action', 'fetch_browse');
|
||||
|
||||
const response: any = await fetchApi(this.site + 'ajax', {
|
||||
body: data,
|
||||
method: 'POST',
|
||||
}).then(res => res.json());
|
||||
|
||||
response.data.posts.forEach((element: any) =>
|
||||
novels.push({
|
||||
name: element.title,
|
||||
cover: element.thumbnail,
|
||||
path: element.permalink.replace(this.site, ''),
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new StorySeedlingPlugin();
|
||||
@@ -0,0 +1,579 @@
|
||||
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 ArchiveOfOurOwn implements Plugin.PluginBase {
|
||||
id = 'archiveofourown';
|
||||
name = 'Archive Of Our Own';
|
||||
version = '1.0.3';
|
||||
icon = 'src/en/ao3/icon.png';
|
||||
site = 'https://archiveofourown.org/';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI): Plugin.NovelItem[] {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('li.work').each((idx, ele) => {
|
||||
const novelName = loadedCheerio(ele)
|
||||
.find('h4.heading > a')
|
||||
.first()
|
||||
.text()
|
||||
.trim();
|
||||
const novelUrl = loadedCheerio(ele)
|
||||
.find('h4.heading > a')
|
||||
.first()
|
||||
.attr('href')
|
||||
?.trim();
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: defaultCover, // No cover image
|
||||
path: novelUrl.slice(1),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ showLatestNovels, filters }: Plugin.PopularNovelsOptions<Filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
// Base URL and common parameters
|
||||
let link = `${this.site}works/search?page=${page}&work_search%5Blanguage_id%5D=en`;
|
||||
|
||||
// Apply sorting based on showLatestNovels
|
||||
if (showLatestNovels) {
|
||||
link += `&work_search%5Bsort_column%5D=revised_at&work_search%5Bsort_direction%5D=${filters.sortdir.value}`;
|
||||
} else if (filters) {
|
||||
link += `&work_search%5Bsort_column%5D=${filters.sort.value}&work_search%5Bsort_direction%5D=${filters.sortdir.value}`;
|
||||
}
|
||||
|
||||
// Apply additional filters
|
||||
if (filters) {
|
||||
// if (filters.genre.value !== '') link += `&work_search%5Bfandom_names%5D=${filters.genre.value}`;
|
||||
if (filters.completion.value !== '')
|
||||
link += `&work_search%5Bcomplete%5D=${filters.completion.value}`;
|
||||
if (filters.crossover.value !== '')
|
||||
link += `&work_search%5Bcrossover%5D=${filters.crossover.value}`;
|
||||
if (filters.categories.value.length > 0) {
|
||||
filters.categories.value.forEach((category: string) => {
|
||||
link += `&work_search%5Bcategory_ids%5D%5B%5D=${category}`;
|
||||
});
|
||||
}
|
||||
if (filters.warningsFilter.value.length > 0) {
|
||||
filters.warningsFilter.value.forEach((warning: string) => {
|
||||
link += `&work_search%5Barchive_warning_ids%5D%5B%5D=${warning}`;
|
||||
});
|
||||
}
|
||||
if (filters.singlechap.value !== '')
|
||||
link += `&work_search%5Bsingle_chapter%5D=${filters.singlechap.value}`;
|
||||
if (filters.author.value !== '')
|
||||
link += `&work_search%5Bcreators%5D=${filters.author.value}`;
|
||||
if (
|
||||
filters.dateFilter.value !== '' &&
|
||||
filters.dateIncrements.value !== ''
|
||||
) {
|
||||
link += `&work_search%5Brevised_at%5D=${filters.dateFilter.value}+${filters.dateIncrements.value}`;
|
||||
}
|
||||
if (filters.words.value !== '')
|
||||
link += `&work_search%5Bword_count%5D=${filters.words.value}`;
|
||||
if (filters.hits.value !== '')
|
||||
link += `&work_search%5Bhits%5D=${filters.hits.value}`;
|
||||
if (filters.bookmarks.value !== '')
|
||||
link += `&work_search%5Bbookmarks_count%5D=${filters.bookmarks.value}`;
|
||||
if (filters.comments.value !== '')
|
||||
link += `&work_search%5Bcomments_count%5D=${filters.comments.value}`;
|
||||
if (filters.kudos.value !== '')
|
||||
link += `&work_search%5Bkudos_count%5D=${filters.kudos.value}`;
|
||||
}
|
||||
|
||||
const body = await fetchApi(link).then(r => r.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
async parseNovel(novelUrl: string): Promise<Plugin.SourceNovel> {
|
||||
const result = await fetchApi(new URL(novelUrl, this.site).toString());
|
||||
const urlchapter = novelUrl + '/navigate';
|
||||
const chapters = await fetchApi(new URL(urlchapter, this.site).toString());
|
||||
const body = await result.text();
|
||||
const chapterlisttext = await chapters.text();
|
||||
const chapterlistload = parseHTML(chapterlisttext);
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelUrl,
|
||||
name: loadedCheerio('h2.title').text().trim() || 'Untitled',
|
||||
cover: defaultCover, // No cover image available
|
||||
status: loadedCheerio('dt.status').text().includes('Updated')
|
||||
? 'Ongoing'
|
||||
: 'Completed',
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
novel.author = loadedCheerio('a[rel="author"]')
|
||||
.map((i, el) => loadedCheerio(el).text().trim())
|
||||
.get()
|
||||
.join(', ');
|
||||
novel.genres = Array.from(loadedCheerio('dd.freeform.tags li a.tag'))
|
||||
.map(el => loadedCheerio(el).text().trim())
|
||||
.join(',');
|
||||
const summary = loadedCheerio('blockquote.userstuff').text().trim();
|
||||
const fandom = Array.from(loadedCheerio('dd.fandom.tags li a.tag'))
|
||||
.map(el => loadedCheerio(el).text().trim())
|
||||
.join(',');
|
||||
const rating = Array.from(loadedCheerio('dd.rating.tags li a.tag'))
|
||||
.map(el => loadedCheerio(el).text().trim())
|
||||
.join(',');
|
||||
const warning = Array.from(loadedCheerio('dd.warning.tags li a.tag'))
|
||||
.map(el => loadedCheerio(el).text().trim())
|
||||
.join(',');
|
||||
const series = Array.from(loadedCheerio('dd.series li a.tag'))
|
||||
.map(el => loadedCheerio(el).text().trim())
|
||||
.join(',');
|
||||
const relation = Array.from(loadedCheerio('dd.relationship.tags li a.tag'))
|
||||
.map(el => loadedCheerio(el).text().trim())
|
||||
.join(',');
|
||||
const character = Array.from(loadedCheerio('dd.character.tags li a.tag'))
|
||||
.map(el => loadedCheerio(el).text().trim())
|
||||
.join(',');
|
||||
const stats = Array.from(loadedCheerio('dd.stats li a.tag'))
|
||||
.map(el => loadedCheerio(el).text().trim())
|
||||
.join(',');
|
||||
novel.summary = `Fandom:\n${fandom}\n\nRating:\n${rating}\n\nWarning:\n${warning}\n\nSummary:\n${summary}\n\nSeries:\n${series}\n\nRelationships:\n${relation}\n\nCharacters:\n${character}\n\nStats:\n${stats}`;
|
||||
const chapterItems: Plugin.ChapterItem[] = [];
|
||||
const longReleaseDate: string[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
chapterlistload('ol.index').each((i, ele) => {
|
||||
chapterlistload(ele)
|
||||
.find('li')
|
||||
.each((i, el) => {
|
||||
const chapterNameMatch = chapterlistload(el).find('a').text().trim();
|
||||
const releaseTimeText = chapterlistload(el)
|
||||
.find('span.datetime')
|
||||
.text()
|
||||
.replace(/\(([^)]+)\)/g, '$1')
|
||||
.trim();
|
||||
const releaseTime = releaseTimeText
|
||||
? new Date(releaseTimeText).toISOString()
|
||||
: '';
|
||||
longReleaseDate.push(releaseTime);
|
||||
});
|
||||
});
|
||||
const releaseTimeText = loadedCheerio('.wrapper dd.published')
|
||||
.text()
|
||||
.trim();
|
||||
const releaseTime = releaseTimeText
|
||||
? new Date(releaseTimeText).toISOString()
|
||||
: '';
|
||||
let dateCounter = 0;
|
||||
if (loadedCheerio('#chapter_index select').length > 0) {
|
||||
loadedCheerio('#chapter_index select').each((i, selectEl) => {
|
||||
loadedCheerio(selectEl)
|
||||
.find('option')
|
||||
.each((i, el) => {
|
||||
const chapterName = loadedCheerio(el).text().trim();
|
||||
const chapterUrlCode = loadedCheerio(el).attr('value')?.trim();
|
||||
const chapterUrl = `${novelUrl}/chapters/${chapterUrlCode}`;
|
||||
const releaseDate: string = longReleaseDate[dateCounter];
|
||||
dateCounter++;
|
||||
if (chapterUrl) {
|
||||
chapterItems.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl,
|
||||
releaseTime: releaseDate,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if (chapterItems.length === 0) {
|
||||
loadedCheerio('#chapters h3.title').each((i, titleEl) => {
|
||||
const fullTitleText = loadedCheerio(titleEl).text().trim();
|
||||
const chapterNameMatch = fullTitleText.match(/:\s*(.*)$/);
|
||||
let chapterName = chapterNameMatch ? chapterNameMatch[1].trim() : '';
|
||||
const chapterUrlRaw = loadedCheerio(titleEl)
|
||||
.find('a')
|
||||
.attr('href')
|
||||
?.trim();
|
||||
const chapterUrlCode = chapterUrlRaw?.match(/\/chapters\/(\d+)/)?.[1];
|
||||
const chapterUrl = `${novelUrl}/chapters/${chapterUrlCode}`;
|
||||
|
||||
if (chapterUrl) {
|
||||
if (chapterName === '') {
|
||||
const novelTitle = loadedCheerio('.work .title.heading')
|
||||
.text()
|
||||
.trim();
|
||||
chapterName = novelTitle;
|
||||
}
|
||||
chapterItems.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl,
|
||||
releaseTime: releaseTime,
|
||||
});
|
||||
}
|
||||
});
|
||||
if (chapterItems.length === 0) {
|
||||
loadedCheerio('.work.navigation.actions li a').each((i, el) => {
|
||||
const href = loadedCheerio(el).attr('href');
|
||||
if (href && href.includes('/downloads/')) {
|
||||
const chapterUrlCodeMatch = href.match(/updated_at=(\d+)/);
|
||||
const chapterUrlCode = chapterUrlCodeMatch
|
||||
? chapterUrlCodeMatch[1]
|
||||
: null;
|
||||
let chapterName = loadedCheerio('h2.title.heading').text().trim();
|
||||
|
||||
const chapterUrl = `${novelUrl}/chapters/${chapterUrlCode}`;
|
||||
|
||||
if (chapterUrl) {
|
||||
if (chapterName === '') {
|
||||
const novelTitle = loadedCheerio('.work .title.heading')
|
||||
.text()
|
||||
.trim();
|
||||
chapterName = novelTitle;
|
||||
}
|
||||
chapterItems.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl,
|
||||
releaseTime: releaseTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
novel.chapters = chapterItems;
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
loadedCheerio('h3.title').each((i, el) => {
|
||||
const $h3 = loadedCheerio(el);
|
||||
const $a = $h3.find('a');
|
||||
$a.removeAttr('href');
|
||||
const aText = $a.text().trim();
|
||||
const nextSiblingText = $h3
|
||||
.contents()
|
||||
.filter((_, node) => node.nodeType === 3)
|
||||
.text()
|
||||
.trim();
|
||||
$h3.html(`${aText}<br>${nextSiblingText}`);
|
||||
});
|
||||
loadedCheerio('h3.landmark.heading#work').remove();
|
||||
|
||||
const chapterText = loadedCheerio('div#chapters > div').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const searchUrl = `${this.site}works/search?page=${page}&work_search%5Blanguage_id%5D=en&work_search%5Bquery%5D=${encodeURIComponent(searchTerm)}`;
|
||||
|
||||
const result = await fetchApi(searchUrl);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
filters = {
|
||||
sort: {
|
||||
value: 'hits',
|
||||
label: 'Sort by',
|
||||
options: [
|
||||
{ label: 'Best Match', value: '_score' },
|
||||
{ label: 'Hits', value: 'hits' },
|
||||
{ label: 'Kudos', value: 'kudos' },
|
||||
{ label: 'Comments', value: 'comments' },
|
||||
{ label: 'Bookmarks', value: 'bookmarks' },
|
||||
{ label: 'Word Count', value: 'word_count' },
|
||||
{ label: 'Date Updated', value: 'revised_at' },
|
||||
{ label: 'Date Posted', value: 'created_at' },
|
||||
{ label: 'Author', value: 'authors_to_sort_on' },
|
||||
{ label: 'Title', value: 'title_to_sort_on' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
sortdir: {
|
||||
value: 'desc',
|
||||
label: 'Sort direction',
|
||||
options: [
|
||||
{ label: 'Descending', value: 'desc' },
|
||||
{ label: 'Ascending', value: 'asc' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
ratings: {
|
||||
value: '',
|
||||
label: 'Ratings',
|
||||
options: [
|
||||
{ label: 'Not Rated', value: '9' },
|
||||
{ label: 'General Audiences', value: '10' },
|
||||
{ label: 'Teen And Up Audiences', value: '11' },
|
||||
{ label: 'Mature', value: '12' },
|
||||
{ label: 'Explicit', value: '13' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
language: {
|
||||
value: 'en',
|
||||
label: 'Language',
|
||||
options: [
|
||||
{ label: 'None', value: '' },
|
||||
{ label: 'af Soomaali', value: 'so' },
|
||||
{ label: 'Afrikaans', value: 'afr' },
|
||||
{ label: 'Aynu itak | アイヌ イタㇰ', value: 'ain' },
|
||||
{ label: 'العربية', value: 'ar' },
|
||||
{ label: 'አማርኛ', value: 'amh' },
|
||||
{ label: '𓂋𓏺𓈖 𓆎𓅓𓏏𓊖', value: 'egy' },
|
||||
{ label: 'ܐܪܡܝܐ | ארמיא', value: 'arc' },
|
||||
{ label: 'հայերեն', value: 'hy' },
|
||||
{ label: 'American Sign Language', value: 'ase' },
|
||||
{ label: 'asturianu', value: 'ast' },
|
||||
{ label: 'Bahasa Indonesia', value: 'id' },
|
||||
{ label: 'Bahasa Malaysia', value: 'ms' },
|
||||
{ label: 'Български', value: 'bg' },
|
||||
{ label: 'বাংলা', value: 'bn' },
|
||||
{ label: 'Basa Jawa', value: 'jv' },
|
||||
{ label: 'Башҡорт теле', value: 'ba' },
|
||||
{ label: 'беларуская', value: 'be' },
|
||||
{ label: 'Bosanski', value: 'bos' },
|
||||
{ label: 'Brezhoneg', value: 'br' },
|
||||
{ label: 'Català', value: 'ca' },
|
||||
{ label: 'Cebuano', value: 'ceb' },
|
||||
{ label: 'Čeština', value: 'cs' },
|
||||
{ label: 'Chinuk Wawa', value: 'chn' },
|
||||
{ label: 'къырымтатар тили | qırımtatar tili', value: 'crh' },
|
||||
{ label: 'Cymraeg', value: 'cy' },
|
||||
{ label: 'Dansk', value: 'da' },
|
||||
{ label: 'Deutsch', value: 'de' },
|
||||
{ label: 'eesti keel', value: 'et' },
|
||||
{ label: 'Ελληνικά', value: 'el' },
|
||||
{ label: '𒅴𒂠', value: 'sux' },
|
||||
{ label: 'English', value: 'en' },
|
||||
{ label: 'Eald Englisċ', value: 'ang' },
|
||||
{ label: 'Español', value: 'es' },
|
||||
{ label: 'Esperanto', value: 'eo' },
|
||||
{ label: 'Euskara', value: 'eu' },
|
||||
{ label: 'فارسی', value: 'fa' },
|
||||
{ label: 'Filipino', value: 'fil' },
|
||||
{ label: 'Français', value: 'fr' },
|
||||
{ label: 'Friisk', value: 'frr' },
|
||||
{ label: 'Furlan', value: 'fur' },
|
||||
{ label: 'Gaeilge', value: 'ga' },
|
||||
{ label: 'Gàidhlig', value: 'gd' },
|
||||
{ label: 'Galego', value: 'gl' },
|
||||
{ label: '𐌲𐌿𐍄𐌹𐍃𐌺𐌰', value: 'got' },
|
||||
{ label: 'Creolese', value: 'gyn' },
|
||||
{ label: '中文-客家话', value: 'hak' },
|
||||
{ label: '한국어', value: 'ko' },
|
||||
{ label: 'Hausa | هَرْشَن هَوْسَ', value: 'hau' },
|
||||
{ label: 'हिन्दी', value: 'hi' },
|
||||
{ label: 'Hrvatski', value: 'hr' },
|
||||
{ label: 'ʻŌlelo Hawaiʻi', value: 'haw' },
|
||||
{ label: 'Interlingua', value: 'ia' },
|
||||
{ label: 'isiZulu', value: 'zu' },
|
||||
{ label: 'Íslenska', value: 'is' },
|
||||
{ label: 'Italiano', value: 'it' },
|
||||
{ label: 'עברית', value: 'he' },
|
||||
{ label: 'Kalaallisut', value: 'kal' },
|
||||
{ label: 'ಕನ್ನಡ', value: 'kan' },
|
||||
{ label: 'ქართული', value: 'kat' },
|
||||
{ label: 'Kernewek', value: 'cor' },
|
||||
{ label: 'ភាសាខ្មែរ', value: 'khm' },
|
||||
{ label: 'Khuzdul', value: 'qkz' },
|
||||
{ label: 'Kiswahili', value: 'sw' },
|
||||
{ label: 'kreyòl ayisyen', value: 'ht' },
|
||||
{ label: 'Kurdî | کوردی', value: 'ku' },
|
||||
{ label: 'Кыргызча', value: 'kir' },
|
||||
{ label: 'Langue des signes québécoise', value: 'fcs' },
|
||||
{ label: 'Latviešu valoda', value: 'lv' },
|
||||
{ label: 'Lëtzebuergesch', value: 'lb' },
|
||||
{ label: 'Lietuvių kalba', value: 'lt' },
|
||||
{ label: 'Lingua latina', value: 'la' },
|
||||
{ label: 'Magyar', value: 'hu' },
|
||||
{ label: 'македонски', value: 'mk' },
|
||||
{ label: 'മലയാളം', value: 'ml' },
|
||||
{ label: 'Malti', value: 'mt' },
|
||||
{ label: 'ᠮᠠᠨᠵᡠ ᡤᡳᠰᡠᠨ', value: 'mnc' },
|
||||
{ label: "Mando'a", value: 'qmd' },
|
||||
{ label: 'मराठी', value: 'mr' },
|
||||
{ label: 'Mikisúkî', value: 'mik' },
|
||||
{ label: 'ᠮᠣᠩᠭᠣᠯ ᠪᠢᠴᠢᠭ᠌ | Монгол Кирилл үсэг', value: 'mon' },
|
||||
{ label: 'မြန်မာဘာသာ', value: 'my' },
|
||||
{ label: 'Эрзянь кель', value: 'myv' },
|
||||
{ label: 'Nāhuatl', value: 'nah' },
|
||||
{ label: '中文-闽南话 臺語', value: 'nan' },
|
||||
{ label: 'Nawat', value: 'ppl' },
|
||||
{ label: 'Nederlands', value: 'nl' },
|
||||
{ label: '日本語', value: 'ja' },
|
||||
{ label: 'Norsk', value: 'no' },
|
||||
{ label: 'Азәрбајҹан дили | آذربایجان دیلی', value: 'azj' },
|
||||
{ label: 'Нохчийн мотт', value: 'ce' },
|
||||
{ label: '‘O’odham Ñiok', value: 'ood' },
|
||||
{ label: 'لسان عثمانى', value: 'ota' },
|
||||
{ label: 'پښتو', value: 'ps' },
|
||||
{ label: 'Plattdüütsch', value: 'nds' },
|
||||
{ label: 'Polski', value: 'pl' },
|
||||
{ label: 'Português brasileiro', value: 'ptBR' },
|
||||
{ label: 'Português europeu', value: 'ptPT' },
|
||||
{ label: 'ਪੰਜਾਬੀ', value: 'pa' },
|
||||
{ label: 'qazaqşa | қазақша', value: 'kaz' },
|
||||
{ label: 'Uncategorized Constructed Languages', value: 'qlq' },
|
||||
{ label: 'Quenya', value: 'qya' },
|
||||
{ label: 'Română', value: 'ro' },
|
||||
{ label: 'Русский', value: 'ru' },
|
||||
{ label: 'Scots', value: 'sco' },
|
||||
{ label: 'Shqip', value: 'sq' },
|
||||
{ label: 'Sindarin', value: 'sjn' },
|
||||
{ label: 'සිංහල', value: 'si' },
|
||||
{ label: 'Slovenčina', value: 'sk' },
|
||||
{ label: 'Slovenščina', value: 'slv' },
|
||||
{ label: 'Sprēkō Þiudiskō', value: 'gem' },
|
||||
{ label: 'Српски', value: 'sr' },
|
||||
{ label: 'suomi', value: 'fi' },
|
||||
{ label: 'Svenska', value: 'sv' },
|
||||
{ label: 'தமிழ்', value: 'ta' },
|
||||
{ label: 'татар теле', value: 'tat' },
|
||||
{ label: 'te reo Māori', value: 'mri' },
|
||||
{ label: 'తెలుగు', value: 'tel' },
|
||||
{ label: 'ไทย', value: 'th' },
|
||||
{ label: 'Thermian', value: 'tqx' },
|
||||
{ label: 'བོད་སྐད་', value: 'bod' },
|
||||
{ label: 'Tiếng Việt', value: 'vi' },
|
||||
{ label: 'ϯⲙⲉⲧⲣⲉⲙⲛ̀ⲭⲏⲙⲓ', value: 'cop' },
|
||||
{ label: 'tlhIngan-Hol', value: 'tlh' },
|
||||
{ label: 'toki pona', value: 'tok' },
|
||||
{ label: 'Trinidadian Creole', value: 'trf' },
|
||||
{ label: 'τσακώνικα', value: 'tsd' },
|
||||
{ label: 'ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ', value: 'chr' },
|
||||
{ label: 'Türkçe', value: 'tr' },
|
||||
{ label: 'Українська', value: 'uk' },
|
||||
{ label: 'اُردُو', value: 'urd' },
|
||||
{ label: 'ئۇيغۇر تىلى', value: 'uig' },
|
||||
{ label: 'Volapük', value: 'vol' },
|
||||
{ label: '中文-吴语', value: 'wuu' },
|
||||
{ label: 'יידיש', value: 'yi' },
|
||||
{ label: 'maayaʼ tʼàan', value: 'yua' },
|
||||
{ label: '中文-广东话 粵語', value: 'yue' },
|
||||
{ label: '中文-普通话 國語', value: 'zh' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
completion: {
|
||||
value: '',
|
||||
label: 'Completion Status',
|
||||
options: [
|
||||
{ label: 'All works', value: 'checked' },
|
||||
{ label: 'Complete works only', value: 'T' },
|
||||
{ label: 'Works in progress only', value: 'F' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
crossover: {
|
||||
value: '',
|
||||
label: 'Crossover Status',
|
||||
options: [
|
||||
{ label: 'Include crossovers', value: 'checked' },
|
||||
{ label: 'Exclude crossovers', value: 'T' },
|
||||
{ label: 'Only crossovers', value: 'F' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
categories: {
|
||||
value: [],
|
||||
label: 'Categories',
|
||||
options: [
|
||||
{ label: 'F/F', value: '116' },
|
||||
{ label: 'F/M', value: '22' },
|
||||
{ label: 'Gen', value: '21' },
|
||||
{ label: 'M/M', value: '23' },
|
||||
{ label: 'Multi', value: '2246' },
|
||||
{ label: 'Other', value: '24' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
warningsFilter: {
|
||||
value: [],
|
||||
label: 'Warnings',
|
||||
options: [
|
||||
{ label: 'Creator Chose Not To Use Archive Warnings', value: '14' },
|
||||
{ label: 'Graphic Depictions Of Violence', value: '17' },
|
||||
{ label: 'Major Character Death', value: '18' },
|
||||
{ label: 'No Archive Warnings Apply', value: '16' },
|
||||
{ label: 'Rape/Non-Con', value: '19' },
|
||||
{ label: 'Underage', value: '20' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
singlechap: {
|
||||
value: '',
|
||||
label: 'Single Chapter Stories',
|
||||
options: [{ label: 'Single Chapter', value: '1' }],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
author: {
|
||||
value: '',
|
||||
label: 'Author/Artist',
|
||||
type: FilterTypes.TextInput,
|
||||
},
|
||||
dateFilter: {
|
||||
value: '',
|
||||
label: 'Enter single Number only Date',
|
||||
type: FilterTypes.TextInput,
|
||||
},
|
||||
dateIncrements: {
|
||||
value: 'days+ago',
|
||||
label: 'Must choose date type',
|
||||
options: [
|
||||
{ label: 'Days', value: 'days+ago' },
|
||||
{ label: 'Weeks', value: 'weeks+ago' },
|
||||
{ label: 'Months', value: 'months+ago' },
|
||||
{ label: 'Years', value: 'years+ago' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
words: {
|
||||
value: '',
|
||||
label:
|
||||
'Word Count, exact number eg. 40 or less than eg. <40 or greater than eg. >40 or range eg. 10-100',
|
||||
type: FilterTypes.TextInput,
|
||||
},
|
||||
hits: {
|
||||
value: '',
|
||||
label: 'Hits',
|
||||
type: FilterTypes.TextInput,
|
||||
},
|
||||
bookmarks: {
|
||||
value: '',
|
||||
label: 'Bookmarks',
|
||||
type: FilterTypes.TextInput,
|
||||
},
|
||||
comments: {
|
||||
value: '',
|
||||
label: 'Comments',
|
||||
type: FilterTypes.TextInput,
|
||||
},
|
||||
kudos: {
|
||||
value: '',
|
||||
label: 'Kudos',
|
||||
type: FilterTypes.TextInput,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new ArchiveOfOurOwn();
|
||||
@@ -0,0 +1,272 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
|
||||
class BLN implements Plugin.PluginBase {
|
||||
id = 'BLN';
|
||||
name = 'BestLightNovel';
|
||||
icon = 'src/en/bestlightnovel/icon.png';
|
||||
site = 'https://bestlightnovel.com/';
|
||||
version = '1.0.1';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.update_item.list_category').each((i, el) => {
|
||||
const novelUrl = loadedCheerio(el).find('h3 > a').attr('href');
|
||||
if (!novelUrl) {
|
||||
// TODO: Handle error
|
||||
console.error('No novel url!');
|
||||
return;
|
||||
}
|
||||
|
||||
const novelName = loadedCheerio(el).find('h3 > a').text();
|
||||
const novelCover = loadedCheerio(el).find('img').attr('src');
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
cover: novelCover,
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = this.site + 'novel_list?';
|
||||
link += 'type=' + filters.type.value;
|
||||
link += '&category=' + filters.category.value;
|
||||
link += '&state=' + filters.status.value;
|
||||
link += '&page=' + page;
|
||||
|
||||
const result = await fetchApi(link);
|
||||
if (!result.ok) {
|
||||
console.error(await result.text());
|
||||
// TODO: Cloudflare protection or other error
|
||||
return [];
|
||||
}
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const result = await fetchApi(this.site + novelPath);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.truyen_info_right h1').text().trim() || '',
|
||||
cover: loadedCheerio('.info_image img').attr('src') || defaultCover,
|
||||
summary: loadedCheerio('#noidungm').text()?.trim() || '',
|
||||
chapters: [],
|
||||
};
|
||||
loadedCheerio('ul.truyen_info_right > li').each(function () {
|
||||
const detailName = loadedCheerio(this).find('span').text();
|
||||
const detail = loadedCheerio(this)
|
||||
.find('a')
|
||||
.map((a, ex) => loadedCheerio(ex).text())
|
||||
.toArray()
|
||||
.join(', ');
|
||||
|
||||
switch (detailName) {
|
||||
case 'Author(s): ':
|
||||
novel.author = detail;
|
||||
break;
|
||||
case 'GENRES: ':
|
||||
novel.genres = detail;
|
||||
break;
|
||||
case 'STATUS : ':
|
||||
novel.status =
|
||||
detail === 'Ongoing'
|
||||
? NovelStatus.Ongoing
|
||||
: detail === 'Completed'
|
||||
? NovelStatus.Completed
|
||||
: NovelStatus.Unknown;
|
||||
}
|
||||
});
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('.chapter-list div.row').each((i, el) => {
|
||||
const chapterName = loadedCheerio(el).find('a').text().trim();
|
||||
const releaseDate = loadedCheerio(el).find('span:last').text().trim();
|
||||
|
||||
const months = [
|
||||
'jan',
|
||||
'feb',
|
||||
'mar',
|
||||
'apr',
|
||||
'may',
|
||||
'jun',
|
||||
'jul',
|
||||
'aug',
|
||||
'sep',
|
||||
'oct',
|
||||
'nov',
|
||||
'dec',
|
||||
].join('|');
|
||||
const rx = new RegExp(`(${months})-(\\d{1,2})-(\\d{2})`, 'i').exec(
|
||||
releaseDate,
|
||||
);
|
||||
if (!rx) return;
|
||||
const year = 2000 + +rx[3];
|
||||
const month = months.indexOf(rx[1].toLowerCase());
|
||||
const day = +rx[2];
|
||||
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href');
|
||||
if (!chapterUrl) {
|
||||
// TODO: Handle error
|
||||
console.error('No chapter url!');
|
||||
return;
|
||||
}
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
releaseTime: new Date(year, month, day).toISOString(),
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapter.reverse();
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(this.site + chapterPath);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const chapterText = loadedCheerio('#vung_doc').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}search_novels/${encodeURIComponent(searchTerm)}?page=${page}`;
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
status: {
|
||||
label: 'Status',
|
||||
value: 'all',
|
||||
options: [
|
||||
{ label: 'ALL', value: 'all' },
|
||||
{ label: 'Completed', value: 'completed' },
|
||||
{ label: 'Ongoing', value: 'ongoing' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
type: {
|
||||
value: 'topview',
|
||||
label: 'Type',
|
||||
options: [
|
||||
{ label: 'Recently updated', value: 'latest' },
|
||||
{ label: 'Newest', value: 'newest' },
|
||||
{ label: 'Top view', value: 'topview' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
category: {
|
||||
label: 'Category',
|
||||
value: 'all',
|
||||
options: [
|
||||
{ label: 'ALL', value: 'all' },
|
||||
{ label: 'Action', value: '1' },
|
||||
{ label: 'Adventure', value: '2' },
|
||||
{ label: 'Animals', value: '65' },
|
||||
{ label: 'Arts', value: '40' },
|
||||
{ label: 'Biographies', value: '41' },
|
||||
{ label: 'Business', value: '42' },
|
||||
{ label: 'Chinese', value: '3' },
|
||||
{ label: 'Comedy', value: '4' },
|
||||
{ label: 'Computers', value: '43' },
|
||||
{ label: 'Crafts, Hobbies', value: '45' },
|
||||
{ label: 'Drama', value: '5' },
|
||||
{ label: 'Education', value: '46' },
|
||||
{ label: 'English', value: '6' },
|
||||
{ label: 'Entertainment', value: '47' },
|
||||
{ label: 'Fantasy', value: '7' },
|
||||
{ label: 'Fiction', value: '48' },
|
||||
{ label: 'Gender Bender', value: '8' },
|
||||
{ label: 'Harem', value: '9' },
|
||||
{ label: 'Historical', value: '10' },
|
||||
{ label: 'History', value: '49' },
|
||||
{ label: 'Home', value: '50' },
|
||||
{ label: 'Horror', value: '11' },
|
||||
{ label: 'Humor', value: '51' },
|
||||
{ label: 'Investing', value: '52' },
|
||||
{ label: 'Josei', value: '12' },
|
||||
{ label: 'Korean', value: '13' },
|
||||
{ label: 'Literature', value: '53' },
|
||||
{ label: 'Lolicon', value: '14' },
|
||||
{ label: 'Martial Arts', value: '15' },
|
||||
{ label: 'Mature', value: '16' },
|
||||
{ label: 'Mecha', value: '17' },
|
||||
{ label: 'Memoirs', value: '54' },
|
||||
{ label: 'Mystery', value: '18' },
|
||||
{ label: 'Original', value: '19' },
|
||||
{ label: 'Other Books', value: '66' },
|
||||
{ label: 'Philosophy', value: '55' },
|
||||
{ label: 'Photography', value: '56' },
|
||||
{ label: 'Politics', value: '57' },
|
||||
{ label: 'Professional', value: '58' },
|
||||
{ label: 'Psychological', value: '20' },
|
||||
{ label: 'Reference', value: '59' },
|
||||
{ label: 'Reincarnation', value: '21' },
|
||||
{ label: 'Religion', value: '60' },
|
||||
{ label: 'Romance', value: '22' },
|
||||
{ label: 'School Life', value: '23' },
|
||||
{ label: 'School Stories', value: '67' },
|
||||
{ label: 'Sci-Fi', value: '24' },
|
||||
{ label: 'Seinen', value: '25' },
|
||||
{ label: 'Short Stories', value: '68' },
|
||||
{ label: 'Shotacon', value: '26' },
|
||||
{ label: 'Shoujo', value: '27' },
|
||||
{ label: 'Shoujo Ai', value: '28' },
|
||||
{ label: 'Shounen', value: '29' },
|
||||
{ label: 'Shounen Ai', value: '30' },
|
||||
{ label: 'Slice Of Life', value: '31' },
|
||||
{ label: 'Smut', value: '32' },
|
||||
{ label: 'Social Science', value: '61' },
|
||||
{ label: 'Spirituality', value: '62' },
|
||||
{ label: 'Sports', value: '33' },
|
||||
{ label: 'Supernatural', value: '34' },
|
||||
{ label: 'Teaser', value: '69' },
|
||||
{ label: 'Technical', value: '63' },
|
||||
{ label: 'Technology', value: '64' },
|
||||
{ label: 'Tragedy', value: '35' },
|
||||
{ label: 'Virtual Reality', value: '36' },
|
||||
{ label: 'Wuxia', value: '37' },
|
||||
{ label: 'Xianxia', value: '38' },
|
||||
{ label: 'Xuanhuan', value: '39' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new BLN();
|
||||
@@ -0,0 +1,156 @@
|
||||
import { fetchApi, fetchProto, fetchText } from '@/lib/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
import { load as loadCheerio } from 'cheerio';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
|
||||
class Chrysanthemumgarden implements Plugin.PluginBase {
|
||||
id = 'chrysanthemumgarden';
|
||||
name = 'Chrysanthemum Garden';
|
||||
icon = 'src/en/chrysanthemumgarden/icon.png';
|
||||
site = 'https://chrysanthemumgarden.com';
|
||||
version = '1.0.1';
|
||||
filters: Filters | undefined = undefined;
|
||||
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
|
||||
|
||||
//flag indicates whether access to LocalStorage, SesesionStorage is required.
|
||||
webStorageUtilized?: boolean;
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const req = await fetchApi(
|
||||
this.site + (pageNo === 1 ? '/books' : '/books/page/' + pageNo) + '/',
|
||||
);
|
||||
const body = await req.text();
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
return loadedCheerio('article')
|
||||
.map((i, el) => {
|
||||
if (
|
||||
loadedCheerio(el)
|
||||
.find('div.series-genres > a')
|
||||
.text()
|
||||
.includes('Manhua')
|
||||
)
|
||||
return;
|
||||
const href = loadedCheerio(el).find('h2.novel-title > a').attr('href');
|
||||
if (!href) return;
|
||||
return {
|
||||
name: loadedCheerio(el).find('h2.novel-title > a').text(),
|
||||
path: href
|
||||
.replace(this.site, '')
|
||||
.replace(/^\//, '')
|
||||
.replace(/\/$/, ''),
|
||||
cover:
|
||||
loadedCheerio(el)
|
||||
.find('div.novel-cover > img')
|
||||
.attr('data-breeze') || defaultCover,
|
||||
};
|
||||
})
|
||||
.toArray()
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const req = await fetchApi(this.site + '/' + novelPath + '/');
|
||||
const body = await req.text();
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
loadedCheerio('h1.novel-title > span.novel-raw-title').remove();
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h1.novel-title').text(),
|
||||
cover: loadedCheerio('div.novel-cover > img').attr('data-breeze'),
|
||||
summary: loadedCheerio('div.entry-content > p')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join('\n\n'),
|
||||
};
|
||||
|
||||
const novelInfoHtml = loadedCheerio('div.novel-info').html();
|
||||
novel.author = novelInfoHtml
|
||||
? novelInfoHtml.match(/Author:\s*([^<]*)<br>/)?.[1]?.trim()
|
||||
: undefined;
|
||||
novel.genres = [
|
||||
...loadedCheerio('div.series-genres > a')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray(),
|
||||
...loadedCheerio('a.series-tag')
|
||||
.map((i, el) => loadedCheerio(el).text().split('(')[0].trim())
|
||||
.toArray(),
|
||||
].join(', ');
|
||||
|
||||
novel.chapters = loadedCheerio('div.chapter-item > a')
|
||||
.map((i, el) => {
|
||||
const href = loadedCheerio(el).attr('href');
|
||||
if (!href) return;
|
||||
return {
|
||||
name: loadedCheerio(el).text().trim(),
|
||||
path: href
|
||||
.replace(this.site, '')
|
||||
.replace(/^\//, '')
|
||||
.replace(/\/$/, ''),
|
||||
};
|
||||
})
|
||||
.toArray();
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const req = await fetchApi(this.site + '/' + chapterPath + '/');
|
||||
const body = await req.text();
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
|
||||
return loadedCheerio('div#novel-content').html() || '';
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo !== 1) return [];
|
||||
|
||||
const req = await fetchApi(this.site + '/wp-json/cg/novels');
|
||||
const body = await req.json();
|
||||
|
||||
const allNovels: ChrysanthemumGardenNovelItem[] = (
|
||||
body as ChrysanthemumGardenNovel[]
|
||||
).map(
|
||||
(novel: ChrysanthemumGardenNovel): ChrysanthemumGardenNovelItem => ({
|
||||
name: novel.name,
|
||||
path: novel.link
|
||||
.replace(this.site, '')
|
||||
.replace(/\/$/, '')
|
||||
.replace(/^\//, ''),
|
||||
cover: defaultCover,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!allNovels) return [];
|
||||
|
||||
return allNovels.filter(novel =>
|
||||
novel.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
async getAllNovels() {}
|
||||
|
||||
resolveUrl = (path: string, isNovel?: boolean) =>
|
||||
this.site + (isNovel ? '/book/' : '/chapter/') + path;
|
||||
}
|
||||
|
||||
export default new Chrysanthemumgarden();
|
||||
|
||||
type ChrysanthemumGardenNovel = {
|
||||
name: string;
|
||||
link: string;
|
||||
};
|
||||
|
||||
type ChrysanthemumGardenNovelItem = {
|
||||
name: string;
|
||||
path: string;
|
||||
cover: string;
|
||||
};
|
||||
@@ -0,0 +1,301 @@
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Filters, FilterTypes, FilterValueWithType } from '@libs/filterInputs';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
|
||||
//TODO: this looks similar to fictioneer source? maybe use multisrc someday
|
||||
|
||||
class DDLPlugin implements Plugin.PluginBase {
|
||||
id = 'DDL.com';
|
||||
name = 'Divine Dao Library';
|
||||
site = 'https://www.divinedaolibrary.com/';
|
||||
version = '1.1.1';
|
||||
icon = 'src/en/divinedaolibrary/icon.png';
|
||||
|
||||
filters = {
|
||||
category: {
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
label: 'State',
|
||||
value: ['Completed', 'Translating', 'Lost in Voting Poll', 'Dropped'],
|
||||
options: [
|
||||
{ label: 'Completed', value: 'Completed' },
|
||||
{ label: 'Translating', value: 'Translating' },
|
||||
{ label: 'Lost in Voting Poll', value: 'Lost in Voting Poll' },
|
||||
{ label: 'Dropped', value: 'Dropped' },
|
||||
{ label: 'Personally Written', value: 'Personally Written' },
|
||||
],
|
||||
},
|
||||
} satisfies Filters;
|
||||
|
||||
allNovelsCache: readonly (readonly [string, string, string])[] | undefined;
|
||||
novelItemCache = new Map<string, Required<Plugin.NovelItem>>();
|
||||
|
||||
/**
|
||||
* Safely extract the pathname from any URL on {@link site}. Check the root
|
||||
* site as there are novels linking off-site (to Patreon).
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
getPath(url: string): string | undefined {
|
||||
if (!url.startsWith(this.site)) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = url.substring(this.site.length).replace(/(^\/+|\/+$)/g, '');
|
||||
if (trimmed.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an array with an asynchronous function and only return the array items
|
||||
* that successfully were fulfilled with values other than undefined.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async asyncMap<T, U>(
|
||||
collection: readonly T[],
|
||||
callbackfn: (value: T, index: number, array: readonly T[]) => Promise<U>,
|
||||
): Promise<Exclude<U, undefined>[]> {
|
||||
return (await Promise.allSettled(collection.map(callbackfn)))
|
||||
.filter(
|
||||
<U>(
|
||||
p: PromiseSettledResult<U>,
|
||||
): p is PromiseFulfilledResult<Exclude<U, undefined>> =>
|
||||
p.status === 'fulfilled' && p.value !== undefined,
|
||||
)
|
||||
.map(({ value }) => value);
|
||||
}
|
||||
|
||||
/**
|
||||
* DDL links to future (unpublished) chapters from its novel pages. To be
|
||||
* able to report updates correctly, try to figure out which chapter was the
|
||||
* actual latest one to be published.
|
||||
*
|
||||
* @private
|
||||
* @returns the path value of the latest published chapter (or undefined)
|
||||
*/
|
||||
async findLatestChapter(novelPath: string): Promise<string | undefined> {
|
||||
const link = `${this.site}wp-json/wp/v2/categories?slug=${novelPath}`;
|
||||
const guessCategory = await fetchApi(link).then(res => res.json());
|
||||
if (guessCategory.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
const categoryId = guessCategory[0].id;
|
||||
const chapterLink = `${this.site}wp-json/wp/v2/posts?categories=${categoryId}&per_page=1`;
|
||||
const lastChapter = await fetchApi(chapterLink).then(res => res.json());
|
||||
if (lastChapter.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
return lastChapter[0].slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on the path, grab all the available information about a novel. Used
|
||||
* to seed the {@link novelItemCache} as well as to fetch the actual single
|
||||
* novel views with chapter list.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async grabNovel(
|
||||
path: string,
|
||||
getChapters = false,
|
||||
): Promise<(Plugin.SourceNovel & Required<Plugin.NovelItem>) | undefined> {
|
||||
const link = `${this.site}wp-json/wp/v2/pages?slug=${path}`;
|
||||
const data = await fetchApi(link).then(res => res.json());
|
||||
if (data.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
const content = parseHTML(data[0].content.rendered);
|
||||
const excerpt = parseHTML(data[0].excerpt.rendered);
|
||||
const image = content('img').first();
|
||||
let chapters: Plugin.ChapterItem[] = [];
|
||||
if (getChapters) {
|
||||
const linkedChapters = content('li > span > a')
|
||||
.map((_, anchorEl) => {
|
||||
const chapterPath = this.getPath(anchorEl.attribs['href']);
|
||||
if (!chapterPath) return;
|
||||
return {
|
||||
name: content(anchorEl).text(),
|
||||
path: chapterPath,
|
||||
} satisfies Plugin.ChapterItem;
|
||||
})
|
||||
.toArray();
|
||||
const lastChapterPath = await this.findLatestChapter(path);
|
||||
if (lastChapterPath) {
|
||||
chapters = linkedChapters.slice(
|
||||
0,
|
||||
1 +
|
||||
linkedChapters.findIndex(
|
||||
chapter => chapter.path === lastChapterPath,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
chapters = linkedChapters;
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: data[0].title.rendered,
|
||||
path,
|
||||
cover: image.attr('data-lazy-src') ?? image.attr('src') ?? defaultCover,
|
||||
author: content('h3')
|
||||
.first()
|
||||
.text()
|
||||
.replace(/^Author:\s*/g, ''),
|
||||
summary: excerpt('p')
|
||||
.first()
|
||||
.text()
|
||||
.replace(/^.+Description\s*/g, ''),
|
||||
chapters,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on the path, grab the {@link Plugin.NovelItem} information from the
|
||||
* cache. If not available in the cache, it will fetch the information.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async grabCachedNovel(path: string): Promise<Plugin.NovelItem | undefined> {
|
||||
const fromCache = this.novelItemCache.get(path);
|
||||
if (fromCache) {
|
||||
return fromCache;
|
||||
}
|
||||
const sourceNovel = await this.grabNovel(path, false);
|
||||
if (sourceNovel === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const novel = {
|
||||
name: sourceNovel.name,
|
||||
path: sourceNovel.path,
|
||||
cover: sourceNovel.cover,
|
||||
};
|
||||
this.novelItemCache.set(path, novel);
|
||||
return novel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of all novels listed on the novels page. Cache it so filters
|
||||
* do not have to refetch the same HTML again.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async grabCachedNovels(): Promise<
|
||||
readonly (readonly [string, string, string])[] | undefined
|
||||
> {
|
||||
if (this.allNovelsCache) {
|
||||
return this.allNovelsCache;
|
||||
}
|
||||
const body = await fetchApi(this.site + 'novels').then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const novels = loadedCheerio('.entry-content ul')
|
||||
.map((_, listEl) => {
|
||||
const list = loadedCheerio(listEl);
|
||||
const category = list.prev().text();
|
||||
return list
|
||||
.find('a')
|
||||
.map((_, anchorEl) => {
|
||||
const path = this.getPath(anchorEl.attribs['href']);
|
||||
if (!path) return;
|
||||
const name = loadedCheerio(anchorEl).text();
|
||||
return [[category, name, path]] as const;
|
||||
})
|
||||
.toArray();
|
||||
})
|
||||
.toArray();
|
||||
this.allNovelsCache = novels;
|
||||
return novels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse list of paths of recently updated novels from the homepage.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async latestNovels(): Promise<readonly string[]> {
|
||||
const body = await fetchApi(this.site).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const novelPaths = loadedCheerio('#main')
|
||||
.find('a[rel="category tag"]')
|
||||
.map((_, anchorEl) => {
|
||||
const path = this.getPath(anchorEl.attribs['href']);
|
||||
return path;
|
||||
})
|
||||
.toArray();
|
||||
const uniqueNovelPaths = new Set(novelPaths);
|
||||
return Array.from(uniqueNovelPaths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse list of paths from novels list, optionally filtered.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async allNovels(
|
||||
categoryFilter: FilterValueWithType<FilterTypes.CheckboxGroup>,
|
||||
) {
|
||||
const allNovels = await this.grabCachedNovels();
|
||||
if (!allNovels) return [];
|
||||
return allNovels
|
||||
.filter(([category]) => categoryFilter.value.includes(category))
|
||||
.map(([, , path]) => path);
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
options: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo !== 1) {
|
||||
return [];
|
||||
}
|
||||
const novelsList = options.showLatestNovels
|
||||
? this.latestNovels()
|
||||
: this.allNovels(options.filters.category);
|
||||
return await this.asyncMap(
|
||||
await novelsList,
|
||||
this.grabCachedNovel.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const sourceNovel = await this.grabNovel(novelPath, true);
|
||||
if (sourceNovel === undefined) {
|
||||
throw new Error(`The path "${novelPath}" could not be resolved.`);
|
||||
}
|
||||
return sourceNovel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const chapterLink = `${this.site}wp-json/wp/v2/posts?slug=${chapterPath}`;
|
||||
const chapter = await fetchApi(chapterLink).then(res => res.json());
|
||||
if (chapter.length !== 1) {
|
||||
return '';
|
||||
}
|
||||
const title = `<h1>${chapter[0].title.rendered}</h1>`;
|
||||
const content = chapter[0].content.rendered;
|
||||
return `${title}${content}`;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo !== 1) {
|
||||
return [];
|
||||
}
|
||||
const allNovels = await this.grabCachedNovels();
|
||||
if (!allNovels) return [];
|
||||
const foundNovels = allNovels
|
||||
.filter(([, name]) =>
|
||||
name.toLocaleLowerCase().includes(searchTerm.toLocaleLowerCase()),
|
||||
)
|
||||
.map(([, , path]) => path);
|
||||
return await this.asyncMap(
|
||||
await foundNovels,
|
||||
this.grabCachedNovel.bind(this),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new DDLPlugin();
|
||||
@@ -0,0 +1,188 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
|
||||
class DreamBigTL implements Plugin.PluginBase {
|
||||
id = 'dreambigtl';
|
||||
name = 'Dream Big Translations';
|
||||
version = '1.0.0';
|
||||
site = 'https://www.dreambigtl.com/';
|
||||
icon = 'src/en/dreambigtl/icon.png';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ showLatestNovels }: Plugin.PopularNovelsOptions,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}p/disclaimer.html`;
|
||||
|
||||
const response = await fetchApi(url);
|
||||
const body = await response.text();
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
const categories = ['New Novels', 'Ongoing Novels', 'Completed Novels'];
|
||||
|
||||
loadedCheerio('#webify-pro-main-nav-menu > li.has-sub').each(
|
||||
(_, categoryElem) => {
|
||||
const categoryName = loadedCheerio(categoryElem)
|
||||
.children('a')
|
||||
.first()
|
||||
.text()
|
||||
.trim();
|
||||
|
||||
if (categories.includes(categoryName)) {
|
||||
const subMenu = loadedCheerio(categoryElem).find('ul.sub-menu.m-sub');
|
||||
subMenu.find('li > a').each((_, novelElem) => {
|
||||
const novelName = loadedCheerio(novelElem).text().trim();
|
||||
const novelUrl = loadedCheerio(novelElem).attr('href');
|
||||
|
||||
if (novelUrl) {
|
||||
novels.push({
|
||||
name: novelName,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
cover: '',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
novels.forEach(novel => console.log('Novel:', novel.name, novel.path));
|
||||
|
||||
if (novels.length === 0) {
|
||||
// Fallback: try to parse everything...
|
||||
loadedCheerio('a').each((_, elem) => {
|
||||
const href = loadedCheerio(elem).attr('href');
|
||||
if (href && href.includes('/p/') && !href.includes('disclaimer')) {
|
||||
const novelName = loadedCheerio(elem).text().trim();
|
||||
novels.push({
|
||||
name: novelName,
|
||||
path: href.replace(this.site, ''),
|
||||
cover: '',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (showLatestNovels) {
|
||||
novels.sort((a, b) => b.path.localeCompare(a.path));
|
||||
}
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const url = this.site + novelPath;
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h1.entry-title').text().trim(),
|
||||
cover: loadedCheerio('.post-body img').first().attr('src') || '',
|
||||
summary: loadedCheerio('.post-body p').first().text().trim(),
|
||||
author:
|
||||
loadedCheerio('.post-body')
|
||||
.text()
|
||||
.match(/Author:\s*(.+)/i)?.[1]
|
||||
?.trim() || 'Unknown',
|
||||
status: this.getNovelStatus(novelPath),
|
||||
chapters: await this.parseChapters(loadedCheerio),
|
||||
};
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapters(
|
||||
loadedCheerio: CheerioAPI,
|
||||
): Promise<Plugin.ChapterItem[]> {
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
// Parse "Free Tier" chapters
|
||||
loadedCheerio('.chapter-panel').each((_, panel) => {
|
||||
const panelTitle = loadedCheerio(panel).find('summary').text().trim();
|
||||
if (panelTitle === 'Free Tier') {
|
||||
loadedCheerio(panel)
|
||||
.find('ul li a')
|
||||
.each((_, chapterEle) => {
|
||||
const chapterName = loadedCheerio(chapterEle).text().trim();
|
||||
const chapterUrl = loadedCheerio(chapterEle).attr('href');
|
||||
if (chapterUrl) {
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Parse "List of Chapters"
|
||||
if (chapters.length === 0) {
|
||||
loadedCheerio(
|
||||
'h2:contains("List of Chapters"), span:contains("List of Chapters")',
|
||||
).each((_, header) => {
|
||||
const chapterList = loadedCheerio(header).next('ul');
|
||||
chapterList.find('li a').each((_, chapterEle) => {
|
||||
const chapterName = loadedCheerio(chapterEle).text().trim();
|
||||
const chapterUrl = loadedCheerio(chapterEle).attr('href');
|
||||
if (chapterUrl) {
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return chapters.reverse();
|
||||
}
|
||||
|
||||
getNovelStatus(novelPath: string): string {
|
||||
if (novelPath.includes('completed')) return 'Completed';
|
||||
return 'Ongoing';
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const url = this.site + chapterPath;
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const chapterTitle = loadedCheerio('h1.entry-title').text().trim();
|
||||
const chapterContent = loadedCheerio('.post-body').html() || '';
|
||||
|
||||
return `<h1>${chapterTitle}</h1>${chapterContent}`;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}search?q=${encodeURIComponent(searchTerm)}`;
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio(
|
||||
'.blog-posts.index-post-wrap .blog-post.hentry.index-post',
|
||||
).each((_, ele) => {
|
||||
const novelName = loadedCheerio(ele).find('.entry-title a').text().trim();
|
||||
const novelUrl = loadedCheerio(ele).find('.entry-title a').attr('href');
|
||||
const novelCover =
|
||||
loadedCheerio(ele).find('.entry-image').attr('data-image') || '';
|
||||
|
||||
if (novelUrl) {
|
||||
novels.push({
|
||||
name: novelName,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
cover: novelCover,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new DreamBigTL();
|
||||
@@ -0,0 +1,199 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
|
||||
class EarlyNovelPlugin implements Plugin.PagePlugin {
|
||||
id = 'earlynovel';
|
||||
name = 'Early Novel';
|
||||
version = '1.0.1';
|
||||
icon = 'src/en/earlynovel/icon.png';
|
||||
site = 'https://earlynovel.net/';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.col-truyen-main > .list-truyen > .row').each((i, el) => {
|
||||
const novelUrl = loadedCheerio(el)
|
||||
.find('h3.truyen-title > a')
|
||||
.attr('href');
|
||||
const novelName = loadedCheerio(el).find('h3.truyen-title > a').text();
|
||||
const novelCover = loadedCheerio(el).find('.lazyimg').attr('data-image');
|
||||
|
||||
if (!novelUrl) return;
|
||||
novels.push({
|
||||
path: novelUrl,
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
});
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
parseChapters(loadedCheerio: CheerioAPI) {
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
loadedCheerio('ul.list-chapter > li').each((i, el) => {
|
||||
const chapterName = loadedCheerio(el).find('.chapter-text').text().trim();
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href')?.slice(1);
|
||||
if (!chapterUrl) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl,
|
||||
});
|
||||
});
|
||||
return chapter;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = this.site;
|
||||
|
||||
if (filters.genres.value.length) link += filters.genres.value;
|
||||
else link += filters.order.value;
|
||||
|
||||
link += `?page=${pageNo}`;
|
||||
|
||||
const body = await fetchApi(link).then(res => res.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
async parseNovel(
|
||||
novelPath: string,
|
||||
): Promise<Plugin.SourceNovel & { totalPages: number }> {
|
||||
const result = await fetchApi(this.site + novelPath);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
loadedCheerio('.glyphicon-menu-right').closest('li').remove();
|
||||
const pagenav = loadedCheerio('.page-nav').prev().find('a');
|
||||
const lastPageStr = pagenav.attr('title')?.match(/(\d+)/);
|
||||
const lastPage = Number(lastPageStr?.[1] || '0');
|
||||
|
||||
const novel: Plugin.SourceNovel & { totalPages: number } = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.book > img').attr('alt') || 'Untitled',
|
||||
cover: loadedCheerio('.book > img').attr('src'),
|
||||
summary: loadedCheerio('.desc-text').text().trim(),
|
||||
chapters: [],
|
||||
totalPages: lastPage,
|
||||
};
|
||||
|
||||
loadedCheerio('.info > div > h3').each(function () {
|
||||
const detailName = loadedCheerio(this).text();
|
||||
const detail = loadedCheerio(this)
|
||||
.siblings()
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
switch (detailName) {
|
||||
case 'Author:':
|
||||
novel.author = detail;
|
||||
break;
|
||||
case 'Status:':
|
||||
novel.status = detail;
|
||||
break;
|
||||
case 'Genre:':
|
||||
novel.genres = detail;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
novel.chapters = this.parseChapters(loadedCheerio);
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
|
||||
const url = this.site + novelPath + '?page=' + page;
|
||||
const body = await fetchApi(url).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const chapters = this.parseChapters(loadedCheerio);
|
||||
return { chapters };
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(this.site + chapterPath);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const chapterText = loadedCheerio('#chapter-c').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}search?keyword=${encodeURIComponent(searchTerm)}`;
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
order: {
|
||||
value: '/most-popular',
|
||||
label: 'Order by',
|
||||
options: [
|
||||
{ label: 'Latest Release', value: '/latest-release-novel' },
|
||||
{ label: 'Hot Novel', value: '/hot-novel' },
|
||||
{ label: 'Completed Novel', value: '/completed-novel' },
|
||||
{ label: 'Most Popular', value: '/most-popular' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genres: {
|
||||
value: '',
|
||||
label: 'Genre',
|
||||
options: [
|
||||
{ label: 'None', value: '' },
|
||||
{ label: 'Action', value: '/genre/action-1' },
|
||||
{ label: 'Adult', value: '/genre/adult-2' },
|
||||
{ label: 'Adventure', value: '/genre/adventure-3' },
|
||||
{ label: 'Comedy', value: '/genre/comedy-4' },
|
||||
{ label: 'Drama', value: '/genre/drama-5' },
|
||||
{ label: 'Ecchi', value: '/genre/ecchi-6' },
|
||||
{ label: 'Fantasy', value: '/genre/fantasy-7' },
|
||||
{ label: 'Gender Bender', value: '/genre/gender-bender-8' },
|
||||
{ label: 'Harem', value: '/genre/harem-9' },
|
||||
{ label: 'Historical', value: '/genre/historical-10' },
|
||||
{ label: 'Horror', value: '/genre/horror-11' },
|
||||
{ label: 'Josei', value: '/genre/josei-12' },
|
||||
{ label: 'Martial Arts', value: '/genre/martial-arts-13' },
|
||||
{ label: 'Mature', value: '/genre/mature-14' },
|
||||
{ label: 'Mecha', value: '/genre/mecha-15' },
|
||||
{ label: 'Mystery', value: '/genre/mystery-16' },
|
||||
{ label: 'Psychological', value: '/genre/psychological-17' },
|
||||
{ label: 'Romance', value: '/genre/romance-18' },
|
||||
{ label: 'School Life', value: '/genre/school-life-19' },
|
||||
{ label: 'Sci-fi', value: '/genre/sci-fi-20' },
|
||||
{ label: 'Seinen', value: '/genre/seinen-21' },
|
||||
{ label: 'Shoujo', value: '/genre/shoujo-22' },
|
||||
{ label: 'Shoujo Ai', value: '/genre/shoujo-ai-23' },
|
||||
{ label: 'Shounen', value: '/genre/shounen-24' },
|
||||
{ label: 'Shounen Ai', value: '/genre/shounen-ai-25' },
|
||||
{ label: 'Slice of Life', value: '/genre/slice-of-life-26' },
|
||||
{ label: 'Smut', value: '/genre/smut-27' },
|
||||
{ label: 'Sports', value: '/genre/sports-28' },
|
||||
{ label: 'Supernatural', value: '/genre/supernatural-29' },
|
||||
{ label: 'Tragedy', value: '/genre/tragedy-30' },
|
||||
{ label: 'Wuxia', value: '/genre/wuxia-31' },
|
||||
{ label: 'Xianxia', value: '/genre/xianxia-32' },
|
||||
{ label: 'Xuanhuan', value: '/genre/xuanhuan-33' },
|
||||
{ label: 'Yaoi', value: '/genre/yaoi-34' },
|
||||
{ label: 'Yuri', value: '/genre/yuri-35' },
|
||||
{ label: 'Video Games', value: '/genre/video-games-36' },
|
||||
{ label: 'Magical Realism', value: '/genre/magical-realism-37' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new EarlyNovelPlugin();
|
||||
@@ -0,0 +1,188 @@
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class FaqWikiUs implements Plugin.PluginBase {
|
||||
id = 'FWK.US';
|
||||
name = 'Faq Wiki';
|
||||
site = 'https://faqwiki.us/';
|
||||
version = '2.0.1';
|
||||
icon = 'src/en/faqwikius/icon.png';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI, searchTerm?: string) {
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.plt-page-item').each((index, element) => {
|
||||
const name = loadedCheerio(element)
|
||||
.text()
|
||||
.replace('Novel – All Chapters', '')
|
||||
.trim();
|
||||
let cover = loadedCheerio(element).find('img').attr('data-ezsrc');
|
||||
|
||||
// Remove the appended query string
|
||||
if (cover) {
|
||||
cover = cover.replace(/\?ezimgfmt=.*$/, ''); // Regular expression magic!
|
||||
} else {
|
||||
cover = loadedCheerio(element).find('img').attr('src');
|
||||
}
|
||||
|
||||
const path = loadedCheerio(element)
|
||||
.find('a')
|
||||
.attr('href')
|
||||
?.replace('tp:', 'tps:')
|
||||
?.replace(this.site, '')
|
||||
?.replace(/\/+$/, '');
|
||||
if (!path) return;
|
||||
|
||||
novels.push({ name, cover, path });
|
||||
});
|
||||
|
||||
if (searchTerm) {
|
||||
novels = novels.filter(novel =>
|
||||
novel.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(): Promise<Plugin.NovelItem[]> {
|
||||
const body = await fetchApi(this.site).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
async parseNovel(path: string): Promise<Plugin.SourceNovel> {
|
||||
const body = await fetchApi(this.site + path).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path,
|
||||
name: '',
|
||||
};
|
||||
|
||||
novel.name = loadedCheerio('.entry-title')
|
||||
.text()
|
||||
.replace('Novel – All Chapters', '')
|
||||
.trim();
|
||||
|
||||
const img = loadedCheerio('.wp-block-image img');
|
||||
const cover = img.attr('data-ezsrc') || img.attr('src');
|
||||
novel.cover = cover?.replace(/\?ezimgfmt=.*$/, ''); // Regular expression magic!
|
||||
|
||||
const status = loadedCheerio(
|
||||
"#lcp_instance_0 +:icontains('complete')",
|
||||
).text();
|
||||
novel.status = status ? NovelStatus.Completed : NovelStatus.Ongoing;
|
||||
|
||||
loadedCheerio('.entry-content strong').each((i, el) => {
|
||||
const key = loadedCheerio(el).text().trim().toLowerCase();
|
||||
const parent = loadedCheerio(el).parent();
|
||||
const values = [parent.text().slice(key.length).trim()].concat(
|
||||
parent
|
||||
.nextUntil('p:has(strong)')
|
||||
.map((e, ax) => loadedCheerio(ax).text().trim())
|
||||
.get(),
|
||||
);
|
||||
let genreText = values.join(' ').trim();
|
||||
const multiWordGenres = [
|
||||
//add more when found
|
||||
'Slice of Life',
|
||||
'School Life',
|
||||
];
|
||||
multiWordGenres.forEach(genre => {
|
||||
genreText = genreText.replace(
|
||||
new RegExp(`\\b${genre}\\b`, 'g'),
|
||||
genre.replace(/ /g, '_'),
|
||||
);
|
||||
});
|
||||
const genres = genreText
|
||||
.split(/\s+/)
|
||||
.map(word => word.replace(/_/g, ' '))
|
||||
.join(', ');
|
||||
|
||||
switch (key) {
|
||||
case 'description:':
|
||||
novel.summary = values.join('\n');
|
||||
break;
|
||||
case 'author(s):':
|
||||
novel.author = values[0];
|
||||
break;
|
||||
case 'genre:':
|
||||
novel.genres = genres;
|
||||
}
|
||||
});
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
loadedCheerio('.lcp_catlist li a').each((chapterIndex, element) => {
|
||||
const name = loadedCheerio(element)
|
||||
.text()
|
||||
.replace(novel.name + '', '')
|
||||
.replace('Novel' + '', '')
|
||||
.trim();
|
||||
const path = loadedCheerio(element)
|
||||
.attr('href')
|
||||
?.replace(this.site, '')
|
||||
?.replace(/\/+$/, '');
|
||||
const chapterNumber = chapterIndex + 1;
|
||||
if (!path) return;
|
||||
chapters.push({
|
||||
name,
|
||||
path,
|
||||
chapterNumber,
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchApi(this.site + chapterPath).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const removal = ['.entry-content span', '.entry-content div', 'script'];
|
||||
removal.map(e => {
|
||||
loadedCheerio(e).remove();
|
||||
});
|
||||
|
||||
const chapterText = loadedCheerio('.entry-content').html()!;
|
||||
// const chapterParagraphs = loadedCheerio('.entry-content p');
|
||||
|
||||
// let chapterContent; // Save this code in case needed
|
||||
|
||||
// if (chapterParagraphs.length < 5) {
|
||||
// //some chapter in this site store their whole text in 1-4 <p>,
|
||||
// chapterContent = chapterParagraphs
|
||||
// .map((index, element) => {
|
||||
// const text = loadedCheerio(element).html();
|
||||
// return text;
|
||||
// })
|
||||
// .get()
|
||||
// .join('\n\n');
|
||||
// } else {
|
||||
// // Multiple paragraphs case
|
||||
// chapterContent = chapterParagraphs
|
||||
// .map((index, element) => {
|
||||
// const text = loadedCheerio(element).text().trim();
|
||||
// return `<p>${text}</p>`;
|
||||
// })
|
||||
// .get()
|
||||
// .join('\n\n');
|
||||
// }
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const body = await fetchApi(this.site).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
return this.parseNovels(loadedCheerio, searchTerm);
|
||||
}
|
||||
}
|
||||
|
||||
export default new FaqWikiUs();
|
||||
@@ -0,0 +1,257 @@
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { load as loadCheerio } from 'cheerio';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { storage } from '@/lib/storage';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
|
||||
class FenrirRealmPlugin implements Plugin.PluginBase {
|
||||
id = 'fenrir';
|
||||
name = 'Fenrir Realm';
|
||||
icon = 'src/en/fenrirrealm/icon.png';
|
||||
site = 'https://fenrirealm.com';
|
||||
version = '1.0.10';
|
||||
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
|
||||
|
||||
hideLocked = storage.get('hideLocked');
|
||||
pluginSettings = {
|
||||
hideLocked: {
|
||||
value: '',
|
||||
label: 'Hide locked chapters',
|
||||
type: 'Switch',
|
||||
},
|
||||
};
|
||||
|
||||
//flag indicates whether access to LocalStorage, SesesionStorage is required.
|
||||
webStorageUtilized?: boolean;
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
// let sort = "updated";
|
||||
let sort = filters.sort.value;
|
||||
if (showLatestNovels) sort = 'latest';
|
||||
const genresFilter = filters.genres.value
|
||||
.map(g => '&genres%5B%5D=' + g)
|
||||
.join('');
|
||||
const res = await fetchApi(
|
||||
`${this.site}/api/series/filter?page=${pageNo}&per_page=20&status=${filters.status.value}&order=${sort}${genresFilter}`,
|
||||
).then(r => r.json());
|
||||
|
||||
return res.data.map(r => this.parseNovelFromApi(r));
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const html = await fetchApi(`${this.site}/series/${novelPath}`, {
|
||||
headers: {
|
||||
'User-Agent': '',
|
||||
},
|
||||
}).then(r => r.text());
|
||||
const loadedCheerio = loadCheerio(html);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h1.my-2').text(),
|
||||
summary: loadedCheerio(
|
||||
'div.overflow-hidden.transition-all.max-h-\\[108px\\] p',
|
||||
)
|
||||
.map((i, el) => loadCheerio(el).text())
|
||||
.get()
|
||||
.join('\n\n'),
|
||||
};
|
||||
// novel.artist = '';
|
||||
novel.author = loadedCheerio(
|
||||
'div.flex-1 > div.mb-3 > a.inline-flex',
|
||||
).text();
|
||||
const coverMatch = html.match(/,cover:"storage\/(.+?)",cover_data_url/);
|
||||
novel.cover = coverMatch
|
||||
? this.site + '/storage/' + coverMatch[1]
|
||||
: defaultCover;
|
||||
novel.genres = loadedCheerio('div.flex-1 > div.flex:not(.mb-3, .mt-5) > a')
|
||||
.map((i, el) => loadCheerio(el).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
novel.status = loadedCheerio('div.flex-1 > div.mb-3 > span.rounded-md')
|
||||
.first()
|
||||
.text();
|
||||
|
||||
let chapters = await fetchApi(
|
||||
this.site + '/api/novels/chapter-list/' + novelPath,
|
||||
).then(r => r.json());
|
||||
|
||||
if (this.hideLocked) {
|
||||
chapters = chapters.filter(c => !c.locked?.price);
|
||||
}
|
||||
|
||||
novel.chapters = chapters
|
||||
.map(c => ({
|
||||
name:
|
||||
(c.locked?.price ? '🔒 ' : '') +
|
||||
(c.group?.index === null ? '' : 'Vol ' + c.group.index + ' ') +
|
||||
'Chapter ' +
|
||||
c.number +
|
||||
(c.title && c.title.trim() != 'Chapter ' + c.number
|
||||
? ' - ' + c.title.replace(/^chapter [0-9]+ . /i, '')
|
||||
: ''),
|
||||
path:
|
||||
novelPath +
|
||||
(c.group?.index === null ? '' : '/' + c.group.slug) +
|
||||
'/chapter-' +
|
||||
c.number,
|
||||
releaseTime: c.created_at,
|
||||
chapterNumber: c.number + c.group?.index * 1000000000000,
|
||||
}))
|
||||
.sort((a, b) => a.chapterNumber - b.chapterNumber);
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const page = await fetchApi(this.site + '/series/' + chapterPath, {
|
||||
headers: {
|
||||
'User-Agent': '',
|
||||
},
|
||||
}).then(r => r.text());
|
||||
return loadCheerio(page)('#reader-area').html() || '';
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
return await fetchApi(
|
||||
`${this.site}/api/series/filter?page=${pageNo}&per_page=20&search=${encodeURIComponent(searchTerm)}`,
|
||||
)
|
||||
.then(r => r.json())
|
||||
.then(r => r.data.map(novel => this.parseNovelFromApi(novel)));
|
||||
}
|
||||
|
||||
parseNovelFromApi(apiData) {
|
||||
return {
|
||||
name: apiData.title,
|
||||
path: apiData.slug,
|
||||
cover: this.site + '/' + apiData.cover,
|
||||
summary: apiData.description,
|
||||
status: apiData.status,
|
||||
genres: apiData.genres.map(g => g.name).join(','),
|
||||
};
|
||||
}
|
||||
|
||||
resolveUrl = (path: string, isNovel?: boolean) =>
|
||||
this.site + '/series/' + path;
|
||||
|
||||
filters = {
|
||||
status: {
|
||||
type: FilterTypes.Picker,
|
||||
label: 'Status',
|
||||
value: 'any',
|
||||
options: [
|
||||
{ label: 'All', value: 'any' },
|
||||
{ label: 'Ongoing', value: 'ongoing' },
|
||||
{
|
||||
label: 'Completed',
|
||||
value: 'completed',
|
||||
},
|
||||
],
|
||||
},
|
||||
sort: {
|
||||
type: FilterTypes.Picker,
|
||||
label: 'Sort',
|
||||
value: 'popular',
|
||||
options: [
|
||||
{ label: 'Popular', value: 'popular' },
|
||||
{ label: 'Latest', value: 'latest' },
|
||||
{ label: 'Updated', value: 'updated' },
|
||||
],
|
||||
},
|
||||
genres: {
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
label: 'Genres',
|
||||
value: [],
|
||||
options: [
|
||||
{ 'label': 'Action', 'value': '1' },
|
||||
{ 'label': 'Adult', 'value': '2' },
|
||||
{
|
||||
'label': 'Adventure',
|
||||
'value': '3',
|
||||
},
|
||||
{ 'label': 'Comedy', 'value': '4' },
|
||||
{ 'label': 'Drama', 'value': '5' },
|
||||
{
|
||||
'label': 'Ecchi',
|
||||
'value': '6',
|
||||
},
|
||||
{ 'label': 'Fantasy', 'value': '7' },
|
||||
{ 'label': 'Gender Bender', 'value': '8' },
|
||||
{
|
||||
'label': 'Harem',
|
||||
'value': '9',
|
||||
},
|
||||
{ 'label': 'Historical', 'value': '10' },
|
||||
{ 'label': 'Horror', 'value': '11' },
|
||||
{
|
||||
'label': 'Josei',
|
||||
'value': '12',
|
||||
},
|
||||
{ 'label': 'Martial Arts', 'value': '13' },
|
||||
{ 'label': 'Mature', 'value': '14' },
|
||||
{
|
||||
'label': 'Mecha',
|
||||
'value': '15',
|
||||
},
|
||||
{ 'label': 'Mystery', 'value': '16' },
|
||||
{ 'label': 'Psychological', 'value': '17' },
|
||||
{
|
||||
'label': 'Romance',
|
||||
'value': '18',
|
||||
},
|
||||
{ 'label': 'School Life', 'value': '19' },
|
||||
{ 'label': 'Sci-fi', 'value': '20' },
|
||||
{
|
||||
'label': 'Seinen',
|
||||
'value': '21',
|
||||
},
|
||||
{ 'label': 'Shoujo', 'value': '22' },
|
||||
{ 'label': 'Shoujo Ai', 'value': '23' },
|
||||
{
|
||||
'label': 'Shounen',
|
||||
'value': '24',
|
||||
},
|
||||
{ 'label': 'Shounen Ai', 'value': '25' },
|
||||
{ 'label': 'Slice of Life', 'value': '26' },
|
||||
{
|
||||
'label': 'Smut',
|
||||
'value': '27',
|
||||
},
|
||||
{ 'label': 'Sports', 'value': '28' },
|
||||
{ 'label': 'Supernatural', 'value': '29' },
|
||||
{
|
||||
'label': 'Tragedy',
|
||||
'value': '30',
|
||||
},
|
||||
{ 'label': 'Wuxia', 'value': '31' },
|
||||
{ 'label': 'Xianxia', 'value': '32' },
|
||||
{
|
||||
'label': 'Xuanhuan',
|
||||
'value': '33',
|
||||
},
|
||||
{ 'label': 'Yaoi', 'value': '34' },
|
||||
{ 'label': 'Yuri', 'value': '35' },
|
||||
],
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new FenrirRealmPlugin();
|
||||
|
||||
//paste into console on site to load
|
||||
async function getUpdatedGenres() {
|
||||
const data = await fetch(
|
||||
'https://fenrirealm.com/api/novels/taxonomy/genres',
|
||||
).then(d => d.json());
|
||||
const genreData = data.map(g => ({ label: g.name, value: g.id.toString() }));
|
||||
console.log(JSON.stringify(genreData));
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
import { load as loadCheerio } from 'cheerio';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class FictionZonePlugin implements Plugin.PagePlugin {
|
||||
id = 'fictionzone';
|
||||
name = 'Fiction Zone';
|
||||
icon = 'src/en/fictionzone/icon.png';
|
||||
site = 'https://fictionzone.net';
|
||||
version = '1.0.1';
|
||||
filters: Filters | undefined = undefined;
|
||||
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
|
||||
|
||||
//flag indicates whether access to LocalStorage, SesesionStorage is required.
|
||||
webStorageUtilized?: boolean;
|
||||
|
||||
cachedNovelIds = new Map<string, string>();
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
return await this.getPage(this.site + '/library?page=' + pageNo);
|
||||
}
|
||||
|
||||
async getPage(url: string) {
|
||||
const req = await fetchApi(url);
|
||||
const body = await req.text();
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
|
||||
return loadedCheerio('div.novel-card')
|
||||
.map((i, el) => {
|
||||
const novelName = loadedCheerio(el).find('a > div.title > h1').text();
|
||||
const novelCover = loadedCheerio(el).find('img').attr('src');
|
||||
const novelUrl = loadedCheerio(el).find('a').attr('href');
|
||||
|
||||
return {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl!.replace(/^\//, '').replace(/\/$/, ''),
|
||||
};
|
||||
})
|
||||
.toArray();
|
||||
}
|
||||
|
||||
async parseNovel(
|
||||
novelPath: string,
|
||||
): Promise<Plugin.SourceNovel & { totalPages: number }> {
|
||||
const req = await fetchApi(this.site + '/' + novelPath);
|
||||
const body = await req.text();
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
|
||||
const novel: Plugin.SourceNovel & { totalPages: number } = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('div.novel-title > h1').text(),
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
// novel.artist = '';
|
||||
novel.author = loadedCheerio('div.novel-author > content').text();
|
||||
novel.cover = loadedCheerio('div.novel-img > img').attr('src');
|
||||
novel.genres = [
|
||||
...loadedCheerio('div.genres > .items > span')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray(),
|
||||
...loadedCheerio('div.tags > .items > a')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray(),
|
||||
].join(',');
|
||||
const status = loadedCheerio('div.novel-status > div.content')
|
||||
.text()
|
||||
.trim();
|
||||
if (status === 'Ongoing') novel.status = NovelStatus.Ongoing;
|
||||
novel.summary = loadedCheerio('#synopsis > div.content').text();
|
||||
|
||||
const nuxtData = loadedCheerio('script#__NUXT_DATA__').html();
|
||||
const parsed = JSON.parse(nuxtData!);
|
||||
let last = null;
|
||||
for (const a of parsed) {
|
||||
if (typeof a === 'string' && a.startsWith('novel_covers/')) break;
|
||||
last = a;
|
||||
}
|
||||
this.cachedNovelIds.set(novelPath, last.toString());
|
||||
// @ts-ignore
|
||||
novel.chapters = loadedCheerio(
|
||||
'div.chapters > div.list-wrapper > div.items > a.chapter',
|
||||
)
|
||||
.map((i, el) => {
|
||||
const chapterName = loadedCheerio(el).find('span.chapter-title').text();
|
||||
const chapterUrl = loadedCheerio(el)
|
||||
.attr('href')
|
||||
?.replace(/^\//, '')
|
||||
.replace(/\/$/, '');
|
||||
const uploadTime = this.parseAgoDate(
|
||||
loadedCheerio(el).find('span.update-date').text(),
|
||||
);
|
||||
|
||||
return {
|
||||
name: chapterName,
|
||||
releaseTime: uploadTime,
|
||||
path: chapterUrl?.replace(/^\//, '').replace(/\/$/, ''),
|
||||
};
|
||||
})
|
||||
.toArray()
|
||||
.filter(chap => !!chap.path);
|
||||
novel.totalPages = parseInt(
|
||||
loadedCheerio('div.chapters ul.el-pager > li:last-child').text(),
|
||||
);
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
|
||||
let id = this.cachedNovelIds.get(novelPath);
|
||||
if (!id) {
|
||||
await this.parseNovel(novelPath);
|
||||
id = this.cachedNovelIds.get(novelPath);
|
||||
}
|
||||
|
||||
const data = await fetchApi(this.site + '/api/__api_party/api-v1', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
'path': '/chapter/all/' + id,
|
||||
'query': { 'page': parseInt(page) },
|
||||
'headers': { 'content-type': 'application/json' },
|
||||
'method': 'get',
|
||||
}),
|
||||
}).then(r => r.json());
|
||||
return {
|
||||
chapters: data._data.map((c: any) => ({
|
||||
name: c.title,
|
||||
releaseTime: new Date(c.created_at).toISOString(),
|
||||
path: novelPath + '/' + c.slug,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const req = await fetchApi(this.site + '/' + chapterPath);
|
||||
const body = await req.text();
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
return loadedCheerio('div.chapter-content').html() || '';
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
return await this.getPage(
|
||||
this.site +
|
||||
'/library?query=' +
|
||||
encodeURIComponent(searchTerm) +
|
||||
'&page=' +
|
||||
pageNo +
|
||||
'&sort=views-all',
|
||||
);
|
||||
}
|
||||
|
||||
parseAgoDate(date: string | undefined) {
|
||||
//parseMadaraDate
|
||||
if (date?.includes('ago')) {
|
||||
const dayJSDate = dayjs(new Date()); // today
|
||||
const timeAgo = date.match(/\d+/)?.[0] || '';
|
||||
const timeAgoInt = parseInt(timeAgo, 10);
|
||||
|
||||
if (!timeAgo) return null; // there is no number!
|
||||
|
||||
if (date.includes('hours ago') || date.includes('hour ago')) {
|
||||
dayJSDate.subtract(timeAgoInt, 'hours'); // go back N hours
|
||||
}
|
||||
|
||||
if (date.includes('days ago') || date.includes('day ago')) {
|
||||
dayJSDate.subtract(timeAgoInt, 'days'); // go back N days
|
||||
}
|
||||
|
||||
if (date.includes('months ago') || date.includes('month ago')) {
|
||||
dayJSDate.subtract(timeAgoInt, 'months'); // go back N months
|
||||
}
|
||||
|
||||
if (date.includes('years ago') || date.includes('year ago')) {
|
||||
dayJSDate.subtract(timeAgoInt, 'years'); // go back N years
|
||||
}
|
||||
|
||||
return dayJSDate.toISOString();
|
||||
}
|
||||
return null; // there is no "ago" so give up
|
||||
}
|
||||
|
||||
resolveUrl = (path: string, isNovel?: boolean) => this.site + '/' + path;
|
||||
}
|
||||
|
||||
export default new FictionZonePlugin();
|
||||
@@ -0,0 +1,331 @@
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Parser } from 'htmlparser2';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class Foxteller implements Plugin.PluginBase {
|
||||
id = 'foxteller';
|
||||
name = 'Foxteller';
|
||||
site = 'https://www.foxteller.com';
|
||||
version = '1.0.2';
|
||||
icon = 'src/en/foxteller/icon.png';
|
||||
|
||||
async safeFecth(url: string, init: any = {}): Promise<string> {
|
||||
const r = await fetchApi(url, init);
|
||||
if (!r.ok)
|
||||
throw new Error(
|
||||
'Could not reach site (' + r.status + ') try to open in webview.',
|
||||
);
|
||||
const data = await r.text();
|
||||
const title = data.match(/<title>(.*?)<\/title>/)?.[1]?.trim();
|
||||
|
||||
if (
|
||||
title &&
|
||||
(title == 'Bot Verification' ||
|
||||
title == 'You are being redirected...' ||
|
||||
title == 'Un instant...' ||
|
||||
title == 'Just a moment...' ||
|
||||
title == 'Redirecting...')
|
||||
)
|
||||
throw new Error('Captcha error, please open in webview');
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url =
|
||||
this.site + '/library?sort=' + filters?.order?.value || 'popularity';
|
||||
|
||||
const body = await this.safeFecth(url);
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
const div = body.match(/<div class="col-md-6">([\s\S]*?)<\/div>/g) || [];
|
||||
div.forEach(elements => {
|
||||
const [, novelUrl, novelName] =
|
||||
elements.match(/<a href="(.*?)" title="(.*?)">/) || [];
|
||||
|
||||
if (novelName && novelUrl) {
|
||||
const novelCover = elements.match(
|
||||
/<img class="img-fluid" src="(.*?)".*>/,
|
||||
);
|
||||
|
||||
novels.push({
|
||||
name: novelName,
|
||||
cover: novelCover?.[1] || defaultCover,
|
||||
path: novelUrl.split('/')[4],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const body = await this.safeFecth(this.resolveUrl(novelPath));
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: '',
|
||||
genres: '',
|
||||
summary: '',
|
||||
status: '',
|
||||
chapters: [] as Plugin.ChapterItem[],
|
||||
};
|
||||
let isParsingGenres = false;
|
||||
let isReadingGenre = false;
|
||||
let isReadingSummary = false;
|
||||
let isParsingInfo = false;
|
||||
let isReadingInfo = false;
|
||||
let isParsingChapterList = false;
|
||||
let isReadingChapter = false;
|
||||
let isPaidChapter = false;
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
let tempChapter = {} as Plugin.ChapterItem;
|
||||
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
// name and cover
|
||||
if (!novel.cover && attribs['class'] === 'img-fluid') {
|
||||
novel.name = attribs['alt'];
|
||||
novel.cover = attribs['src'] || defaultCover;
|
||||
} // genres
|
||||
else if (name === 'div' && attribs['class'] === 'novel-genres') {
|
||||
isParsingGenres = true;
|
||||
} else if (isParsingGenres && name === 'li') {
|
||||
isReadingGenre = true;
|
||||
} // summary
|
||||
else if (name === 'div' && attribs['class'] === 'novel-description') {
|
||||
isReadingSummary = true;
|
||||
} // status
|
||||
else if (name === 'div' && attribs['class'] === 'novel-tags') {
|
||||
isParsingInfo = true;
|
||||
} else if (isParsingInfo && name === 'li') {
|
||||
isReadingInfo = true;
|
||||
}
|
||||
// chapters
|
||||
else if (name === 'div' && attribs['class'] === 'col-md-6') {
|
||||
isParsingChapterList = true;
|
||||
} else if (isParsingChapterList && name === 'a') {
|
||||
isReadingChapter = true;
|
||||
tempChapter.chapterNumber = chapters.length + 1;
|
||||
tempChapter.path = novelPath + '/' + attribs['href'].split('/')[5];
|
||||
} else if (
|
||||
isReadingChapter &&
|
||||
name === 'i' &&
|
||||
attribs['class']?.includes('lock')
|
||||
) {
|
||||
isPaidChapter = true;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
// genres
|
||||
if (isParsingGenres) {
|
||||
if (isReadingGenre) {
|
||||
novel.genres += data + ', ';
|
||||
}
|
||||
} // summary
|
||||
else if (isReadingSummary) {
|
||||
novel.summary += data.trim();
|
||||
} // status
|
||||
else if (isParsingInfo) {
|
||||
if (isReadingInfo) {
|
||||
const detailName = data.toLowerCase().trim();
|
||||
switch (detailName) {
|
||||
case 'completed':
|
||||
novel.status = NovelStatus.Completed;
|
||||
break;
|
||||
case 'ongoing':
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
break;
|
||||
case 'hiatus':
|
||||
novel.status = NovelStatus.OnHiatus;
|
||||
break;
|
||||
default:
|
||||
novel.status = NovelStatus.Unknown;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// chapters
|
||||
else if (isParsingChapterList) {
|
||||
if (isReadingChapter) {
|
||||
tempChapter.name = (tempChapter.name || '') + data;
|
||||
}
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
// genres
|
||||
if (isParsingGenres) {
|
||||
if (isReadingGenre) {
|
||||
isReadingGenre = false; // stop reading genre
|
||||
} else {
|
||||
isParsingGenres = false; // stop parsing genres
|
||||
novel.genres = novel.genres?.slice(0, -2); // remove trailing comma
|
||||
}
|
||||
} // summary
|
||||
else if (isReadingSummary) {
|
||||
if (name === 'hr' || name === 'p') {
|
||||
novel.summary += '\n';
|
||||
} else if (name === 'div') {
|
||||
isReadingSummary = false;
|
||||
}
|
||||
} // status
|
||||
else if (isParsingInfo) {
|
||||
if (name === 'li') {
|
||||
isReadingInfo = false;
|
||||
} else if (name === 'div') {
|
||||
isParsingInfo = false;
|
||||
}
|
||||
} // chapters
|
||||
else if (isParsingChapterList) {
|
||||
if (isReadingChapter) {
|
||||
if (name === 'li') {
|
||||
if (isPaidChapter) {
|
||||
isPaidChapter = false;
|
||||
} else {
|
||||
tempChapter.name = tempChapter.name.trim();
|
||||
chapters.push(tempChapter);
|
||||
}
|
||||
isReadingChapter = false;
|
||||
tempChapter = {} as Plugin.ChapterItem;
|
||||
}
|
||||
} else if (name === 'ul') {
|
||||
isParsingChapterList = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
parser.write(body);
|
||||
parser.end();
|
||||
|
||||
novel.chapters = chapters;
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const res = await this.safeFecth(this.resolveUrl(chapterPath));
|
||||
const novelID = chapterPath.split('/')[0];
|
||||
const chapterID = res.match(/'chapter_id': '([\d]+)'/)?.[1];
|
||||
|
||||
if (!chapterID) throw new Error('No chapter found');
|
||||
|
||||
const { aux } = await fetchApi(this.site + '/aux_dem', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3',
|
||||
'Content-Type': 'application/json;charset=utf-8',
|
||||
},
|
||||
Referer: this.resolveUrl(chapterPath),
|
||||
body: JSON.stringify({ 'x1': novelID, 'x2': chapterID }),
|
||||
}).then(res => res.json());
|
||||
|
||||
if (aux && typeof aux === 'string') {
|
||||
const base64 = aux.replace(/%R([a-f])&/g, (match, code) => {
|
||||
switch (code) {
|
||||
case 'a':
|
||||
return 'A';
|
||||
case 'c':
|
||||
return 'B';
|
||||
case 'b':
|
||||
return 'C';
|
||||
case 'd':
|
||||
return 'D';
|
||||
case 'f':
|
||||
return 'E';
|
||||
case 'e':
|
||||
return 'F';
|
||||
default:
|
||||
return match;
|
||||
}
|
||||
});
|
||||
|
||||
return decodeURIComponent(decodeBase64(base64));
|
||||
}
|
||||
|
||||
throw new Error('This chapter is closed');
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const body = await this.safeFecth(this.site + '/search', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3',
|
||||
'Content-Type': 'application/json;charset=utf-8',
|
||||
},
|
||||
Referer: this.site,
|
||||
body: JSON.stringify({ query: searchTerm }),
|
||||
});
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
const items = body.match(/<a.*>([\s\S]*?)<\/a>/g) || [];
|
||||
items.forEach(elements => {
|
||||
const novelUrl = elements.match(/<a href="(.*?)"/)?.[1] || '';
|
||||
const path = novelUrl.split('/')[4];
|
||||
const name = elements.match(
|
||||
/<span class="ellipsis-1">(.*?)<\/span>/,
|
||||
)?.[1];
|
||||
|
||||
if (name && path) {
|
||||
const cover =
|
||||
elements.match(/<img src="(.*?)".*>/)?.[1] || defaultCover;
|
||||
novels.push({ name, cover, path });
|
||||
}
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
resolveUrl = (path: string) => this.site + '/novel/' + path;
|
||||
|
||||
filters = {
|
||||
order: {
|
||||
value: 'popularity',
|
||||
label: 'Order by',
|
||||
options: [
|
||||
{ label: 'Popular Novels', value: 'popularity' },
|
||||
{ label: 'New Novels', value: 'newest' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new Foxteller();
|
||||
|
||||
const base64Characters =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
|
||||
|
||||
function decodeBase64(encodedString: string) {
|
||||
const hexcode = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < encodedString.length) {
|
||||
const enc1 = base64Characters.indexOf(encodedString.charAt(i++));
|
||||
const enc2 = base64Characters.indexOf(encodedString.charAt(i++));
|
||||
const enc3 = base64Characters.indexOf(encodedString.charAt(i++));
|
||||
const enc4 = base64Characters.indexOf(encodedString.charAt(i++));
|
||||
|
||||
const chr1 = (enc1 << 2) | (enc2 >> 4);
|
||||
const chr2 = ((enc2 & 15) << 4) | (enc3 >> 2);
|
||||
const chr3 = ((enc3 & 3) << 6) | enc4;
|
||||
|
||||
hexcode.push(chr1.toString(16).padStart(2, '0'));
|
||||
|
||||
if (enc3 !== 64) {
|
||||
hexcode.push(chr2.toString(16).padStart(2, '0'));
|
||||
}
|
||||
if (enc4 !== 64) {
|
||||
hexcode.push(chr3.toString(16).padStart(2, '0'));
|
||||
}
|
||||
}
|
||||
|
||||
return '%' + hexcode.join('%');
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
|
||||
/**
|
||||
* Example for novel API:
|
||||
* https://genesistudio.com/novels/dlh/__data.json?x-sveltekit-invalidated=001
|
||||
* -> to view novel remove '__data.json?x-sveltekit-invalidated=001'
|
||||
*
|
||||
* Example for chapter API:
|
||||
* https://genesistudio.com/viewer/2443/__data.json?x-sveltekit-invalidated=001
|
||||
* -> to view chapter remove '__data.json?x-sveltekit-invalidated=001'
|
||||
*/
|
||||
|
||||
class Genesis implements Plugin.PluginBase {
|
||||
id = 'genesistudio';
|
||||
name = 'Genesis';
|
||||
icon = 'src/en/genesis/icon.png';
|
||||
customCSS = 'src/en/genesis/customCSS.css';
|
||||
site = 'https://genesistudio.com';
|
||||
version = '1.1.2';
|
||||
|
||||
imageRequestInit?: Plugin.ImageRequestInit | undefined = {
|
||||
headers: {
|
||||
'referrer': this.site,
|
||||
},
|
||||
};
|
||||
|
||||
async parseNovels(json: any[]): Promise<Plugin.SourceNovel[]> {
|
||||
return json.map((novel: any) => ({
|
||||
name: novel.novel_title,
|
||||
path: `/novels/${novel.abbreviation}`,
|
||||
cover: novel.cover,
|
||||
}));
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo !== 1) return [];
|
||||
let link = `${this.site}/api/novels/search?`;
|
||||
if (showLatestNovels) {
|
||||
link += 'serialization=All&sort=Recent';
|
||||
} else {
|
||||
if (filters?.genres.value.length) {
|
||||
link += 'genres=' + filters.genres.value.join('&genres=') + '&';
|
||||
}
|
||||
if (filters?.storyStatus.value) {
|
||||
link += 'serialization=' + `${filters.storyStatus.value}` + '&';
|
||||
}
|
||||
if (filters?.sort.value) {
|
||||
link += 'sort=' + `${filters.sort.value}`;
|
||||
}
|
||||
}
|
||||
const json = await fetchApi(link).then(r => r.json());
|
||||
return this.parseNovels(json);
|
||||
}
|
||||
|
||||
// Helper function to extract novel data from nodes
|
||||
extractData(nodes: any[]): any {
|
||||
return nodes
|
||||
.filter((node: { type: string }) => node.type === 'data')
|
||||
.map((node: { data: any }) => node.data)[0];
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const url = `${this.site}${novelPath}/__data.json?x-sveltekit-invalidated=001`;
|
||||
|
||||
// Fetch the novel's data in JSON format
|
||||
const json = await fetchApi(url).then(r => r.json());
|
||||
const nodes = json.nodes;
|
||||
|
||||
// Extract the main novel data from the nodes
|
||||
const data = this.extractData(nodes);
|
||||
|
||||
// Initialize the novel object with default values
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: '',
|
||||
cover: '',
|
||||
summary: '',
|
||||
author: '',
|
||||
status: 'Unknown',
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
// Parse and assign novel metadata (title, cover, summary, author, etc.)
|
||||
this.populateNovelMetadata(novel, data);
|
||||
|
||||
// Parse the chapters if available and assign them to the novel object
|
||||
novel.chapters = this.extractChapters(data);
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
// Helper function to populate novel metadata
|
||||
populateNovelMetadata(novel: Plugin.SourceNovel, data: any): void {
|
||||
for (const key in data) {
|
||||
const value = data[key];
|
||||
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'novel_title' in value
|
||||
) {
|
||||
novel.name = data[value.novel_title] || 'Unknown Title';
|
||||
novel.cover = data[value.cover] || '';
|
||||
novel.summary = data[value.synopsis] || '';
|
||||
novel.author = data[value.author] || 'Unknown Author';
|
||||
novel.genres =
|
||||
(data[value.genres] as number[])
|
||||
.map((genreId: number) => data[genreId])
|
||||
.join(', ') || 'Unknown Genre';
|
||||
novel.status = value.release_days ? 'Ongoing' : 'Completed';
|
||||
break; // Break the loop once metadata is found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to extract and format chapters
|
||||
extractChapters(data: any): Plugin.ChapterItem[] {
|
||||
const chapterKey = 'chapters';
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
// Iterate over each property in data to find chapter containers
|
||||
for (const key in data) {
|
||||
const value = data[key];
|
||||
|
||||
// Look for an object with a 'chapters' key
|
||||
if (value && typeof value === 'object' && chapterKey in value) {
|
||||
const chaptersIndexKey = value[chapterKey];
|
||||
const chapterData = data[chaptersIndexKey];
|
||||
if (!chapterData || typeof chapterData !== 'object') continue;
|
||||
|
||||
// Dynamically get chapter indices from every category (free, premium, etc.)
|
||||
let allChapterIndices: number[] = [];
|
||||
for (const category of Object.keys(chapterData)) {
|
||||
const chapterIndices = data[chapterData[category]];
|
||||
if (Array.isArray(chapterIndices)) {
|
||||
allChapterIndices = allChapterIndices.concat(chapterIndices);
|
||||
}
|
||||
}
|
||||
|
||||
// Format each chapter and add only valid ones
|
||||
const formattedChapters = allChapterIndices
|
||||
.map((index: number) => data[index])
|
||||
.map((chapter: any) => this.formatChapter(chapter, data))
|
||||
.filter((chapter): chapter is Plugin.ChapterItem => chapter !== null);
|
||||
|
||||
chapters.push(...formattedChapters);
|
||||
}
|
||||
}
|
||||
|
||||
return chapters;
|
||||
}
|
||||
|
||||
// Helper function to format an individual chapter
|
||||
formatChapter(chapter: any, data: any): Plugin.ChapterItem | null {
|
||||
const { id, chapter_title, chapter_number, required_tier, date_created } =
|
||||
chapter;
|
||||
|
||||
// Destructure from data using computed property names based on chapter keys
|
||||
const {
|
||||
[id]: chapterId,
|
||||
[chapter_title]: chapterTitle,
|
||||
[chapter_number]: chapterNumberVal,
|
||||
[required_tier]: requiredTierVal,
|
||||
[date_created]: dateCreated,
|
||||
} = data;
|
||||
|
||||
// Validate required fields
|
||||
if (
|
||||
chapterId &&
|
||||
chapterTitle &&
|
||||
chapterNumberVal >= 0 &&
|
||||
requiredTierVal !== null &&
|
||||
dateCreated
|
||||
) {
|
||||
const chapterNumber = parseInt(String(chapterNumberVal), 10) || 0;
|
||||
const requiredTier = parseInt(String(requiredTierVal), 10) || 0;
|
||||
|
||||
// Only process chapters that do not require a premium tier
|
||||
if (requiredTier === 0) {
|
||||
return {
|
||||
name: `Chapter ${chapterNumber} - ${chapterTitle}`,
|
||||
path: `/viewer/${chapterId}`,
|
||||
releaseTime: dateCreated,
|
||||
chapterNumber: chapterNumber,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// // Helper function to extract and format chapters
|
||||
// extractChapters(data: any): Plugin.ChapterItem[] {
|
||||
// for (const key in data) {
|
||||
// const value = data[key];
|
||||
//
|
||||
// // Change string here if the chapters are stored under a different key
|
||||
// const chapterKey = 'chapters';
|
||||
// if (typeof value === 'object' && value !== null && chapterKey in value) {
|
||||
// const chapterData = this.decodeData(data[value[chapterKey]]);
|
||||
//
|
||||
// // Object.values will give us an array of arrays (any[][])
|
||||
// const chapterArrays: any[][] = Object.values(chapterData);
|
||||
//
|
||||
// // Flatten and format the chapters
|
||||
// return chapterArrays.flatMap((chapters: any[]) => {
|
||||
// return chapters
|
||||
// .map((chapter: any) => this.formatChapter(chapter))
|
||||
// .filter(
|
||||
// (chapter): chapter is Plugin.ChapterItem => chapter !== null,
|
||||
// );
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return [];
|
||||
// }
|
||||
//
|
||||
// // Helper function to format an individual chapter
|
||||
// formatChapter(chapter: any): Plugin.ChapterItem | null {
|
||||
// const { id, chapter_title, chapter_number, required_tier, date_created } =
|
||||
// chapter;
|
||||
//
|
||||
// // Ensure required fields are present and valid
|
||||
// if (
|
||||
// id &&
|
||||
// chapter_title &&
|
||||
// chapter_number >= 0 &&
|
||||
// required_tier !== null &&
|
||||
// date_created
|
||||
// ) {
|
||||
// const number = parseInt(chapter_number, 10) || 0;
|
||||
// const requiredTier = parseInt(required_tier, 10) || 0;
|
||||
//
|
||||
// // Only process chapters with a 'requiredTier' of 0
|
||||
// if (requiredTier === 0) {
|
||||
// return {
|
||||
// name: `Chapter ${number}: ${chapter_title}`,
|
||||
// path: `/viewer/${id}`,
|
||||
// releaseTime: date_created,
|
||||
// chapterNumber: number,
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return null;
|
||||
// }
|
||||
//
|
||||
// decodeData(code: any) {
|
||||
// const offset = this.getOffsetIndex(code);
|
||||
// const params = this.getDecodeParams(code);
|
||||
// const constant = this.getConstant(code);
|
||||
// const data = this.getStringsArrayRaw(code);
|
||||
//
|
||||
// const getDataAt = (x: number) => data[x - offset];
|
||||
//
|
||||
// //reshuffle data array
|
||||
// // eslint-disable-next-line no-constant-condition
|
||||
// while (true) {
|
||||
// try {
|
||||
// const some_number = this.applyDecodeParams(params, getDataAt);
|
||||
// if (some_number === constant) break;
|
||||
// else data.push(data.shift());
|
||||
// } catch (err) {
|
||||
// data.push(data.shift());
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return this.getChapterData(code, getDataAt);
|
||||
// }
|
||||
//
|
||||
// getOffsetIndex(code: string) {
|
||||
// // @ts-ignore
|
||||
// const string = /{(\w+)=\1-0x(?<offset>[0-9a-f]+);/.exec(code).groups.offset;
|
||||
// return parseInt(string, 16);
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * @returns {string[]}
|
||||
// */
|
||||
// getStringsArrayRaw(code: string) {
|
||||
// // @ts-ignore
|
||||
// let json = /function \w+\(\){var \w+=(?<array>\['.+']);/.exec(code).groups
|
||||
// .array;
|
||||
//
|
||||
// //replace string single quotes with double quotes and add escaped chars
|
||||
// json = json.replace(/'(.+?)'([,\]])/g, (match, p1, p2) => {
|
||||
// return `"${p1.replace(/\\x([0-9a-z]{2})/g, (match: any, p1: string) => {
|
||||
// //hexadecimal unicode escape chars
|
||||
// return String.fromCharCode(parseInt(p1, 16));
|
||||
// })}"${p2}`;
|
||||
// });
|
||||
//
|
||||
// return JSON.parse(json);
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * @returns {{offset: number, divider: number, negated: boolean}[][]}
|
||||
// */
|
||||
// getDecodeParams(code: string) {
|
||||
// // @ts-ignore
|
||||
// const jsDecodeInt = /while\(!!\[]\){try{var \w+=(?<code>.+?);/.exec(code)
|
||||
// .groups.code;
|
||||
// const decodeSections = jsDecodeInt.split('+');
|
||||
// const params = [];
|
||||
// for (const section of decodeSections) {
|
||||
// params.push(this.decodeParamSection(section));
|
||||
// }
|
||||
// return params;
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * @param {string} section
|
||||
// * @returns {{offset: number, divider: number, negated: boolean}[]}
|
||||
// */
|
||||
// decodeParamSection(section: string) {
|
||||
// const sections = section.split('*');
|
||||
// const params = [];
|
||||
// for (const section of sections) {
|
||||
// // @ts-ignore
|
||||
// const offsetStr = /parseInt\(\w+\(0x(?<offset>[0-9a-f]+)\)\)/.exec(
|
||||
// section,
|
||||
// ).groups.offset;
|
||||
// const offset = parseInt(offsetStr, 16);
|
||||
// // @ts-ignore
|
||||
// const dividerStr = /\/0x(?<divider>[0-9a-f]+)/.exec(section).groups
|
||||
// .divider;
|
||||
// const divider = parseInt(dividerStr, 16);
|
||||
// const negated = section.includes('-');
|
||||
// params.push({ offset, divider, negated });
|
||||
// }
|
||||
// return params;
|
||||
// }
|
||||
//
|
||||
// getConstant(code: string) {
|
||||
// // @ts-ignore
|
||||
// const constantStr = /}}}\(\w+,0x(?<constant>[0-9a-f]+)\),/.exec(code).groups
|
||||
// .constant;
|
||||
// return parseInt(constantStr, 16);
|
||||
// }
|
||||
//
|
||||
// getChapterData(
|
||||
// code: string,
|
||||
// getDataAt: { (x: number): any; (arg0: number): any },
|
||||
// ) {
|
||||
// let chapterDataStr =
|
||||
// // @ts-ignore
|
||||
// /\),\(function\(\){var \w+=\w+;return(?<data>{.+?});/.exec(code).groups
|
||||
// .data;
|
||||
//
|
||||
// //replace hex with decimal
|
||||
// chapterDataStr = chapterDataStr.replace(/:0x([0-9a-f]+)/g, (match, p1) => {
|
||||
// const hex = parseInt(p1, 16);
|
||||
// return `: ${hex}`;
|
||||
// });
|
||||
//
|
||||
// //replace ![] with false and !![] with true
|
||||
// chapterDataStr = chapterDataStr
|
||||
// .replace(/:!!\[]/g, ':true')
|
||||
// .replace(/:!\[]/g, ':false');
|
||||
//
|
||||
// //replace string single quotes with double quotes and add escaped chars
|
||||
// chapterDataStr = chapterDataStr.replace(
|
||||
// /'(.+?)'([,\]}:])/g,
|
||||
// (match, p1, p2) => {
|
||||
// return `"${p1.replace(/\\x([0-9a-z]{2})/g, (match: any, p1: string) => {
|
||||
// //hexadecimal unicode escape chars
|
||||
// return String.fromCharCode(parseInt(p1, 16));
|
||||
// })}"${p2}`;
|
||||
// },
|
||||
// );
|
||||
//
|
||||
// //parse the data getting methods
|
||||
// chapterDataStr = chapterDataStr.replace(
|
||||
// // @ts-ignore
|
||||
// /:\w+\(0x(?<offset>[0-9a-f]+)\)/g,
|
||||
// (match, p1) => {
|
||||
// const offset = parseInt(p1, 16);
|
||||
// return `:${JSON.stringify(getDataAt(offset))}`;
|
||||
// },
|
||||
// );
|
||||
//
|
||||
// return JSON.parse(chapterDataStr);
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * @param {{offset: number, divider: number, negated: boolean}[][]} params
|
||||
// * @param {function(number): string} getDataAt
|
||||
// */
|
||||
// applyDecodeParams(
|
||||
// params: { offset: number; divider: number; negated: boolean }[][],
|
||||
// getDataAt: { (x: number): any; (arg0: any): string },
|
||||
// ) {
|
||||
// let res = 0;
|
||||
// for (const paramAdd of params) {
|
||||
// let resInner = 1;
|
||||
// for (const paramMul of paramAdd) {
|
||||
// resInner *= parseInt(getDataAt(paramMul.offset)) / paramMul.divider;
|
||||
// if (paramMul.negated) resInner *= -1;
|
||||
// }
|
||||
// res += resInner;
|
||||
// }
|
||||
// return res;
|
||||
// }
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const url = `${this.site}${chapterPath}/__data.json?x-sveltekit-invalidated=001`;
|
||||
|
||||
// Fetch the novel's data in JSON format
|
||||
const json = await fetchApi(url).then(r => r.json());
|
||||
const nodes = json.nodes;
|
||||
|
||||
// Extract the main novel data from the nodes
|
||||
const data = this.extractData(nodes);
|
||||
|
||||
// Look for chapter container with required fields
|
||||
const contentKey = 'content';
|
||||
const notesKey = 'notes';
|
||||
const footnotesKey = 'footnotes';
|
||||
|
||||
// Iterate over each property in data to find chapter containers
|
||||
for (const key in data) {
|
||||
const mapping = data[key];
|
||||
|
||||
// Check container for keys that match the required fields
|
||||
if (
|
||||
mapping &&
|
||||
typeof mapping === 'object' &&
|
||||
contentKey in mapping &&
|
||||
notesKey in mapping &&
|
||||
footnotesKey in mapping
|
||||
) {
|
||||
// Retrieve the chapter's content, notes, and footnotes using the mapping.
|
||||
const content = data[mapping[contentKey]];
|
||||
const notes = data[mapping[notesKey]];
|
||||
const footnotes = data[mapping[footnotesKey]];
|
||||
|
||||
// Return the combined parts with appropriate formatting
|
||||
return (
|
||||
content +
|
||||
(notes ? `<h2>Notes</h2><br>${notes}` : '') +
|
||||
(footnotes ?? '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If no mapping object was found, return an empty string or handle appropriately.
|
||||
return '';
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.SourceNovel[]> {
|
||||
if (pageNo !== 1) return [];
|
||||
const url = `${this.site}/api/novels/search?serialization=All&sort=Popular&title=${encodeURIComponent(searchTerm)}`;
|
||||
const json = await fetchApi(url).then(r => r.json());
|
||||
return this.parseNovels(json);
|
||||
}
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Sort Results By',
|
||||
value: 'Popular',
|
||||
options: [
|
||||
{ label: 'Popular', value: 'Popular' },
|
||||
{ label: 'Recent', value: 'Recent' },
|
||||
{ label: 'Views', value: 'Views' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
storyStatus: {
|
||||
label: 'Status',
|
||||
value: 'All',
|
||||
options: [
|
||||
{ label: 'All', value: 'All' },
|
||||
{ label: 'Ongoing', value: 'Ongoing' },
|
||||
{ label: 'Completed', value: 'Completed' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genres: {
|
||||
label: 'Genres',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: 'Action', value: 'Action' },
|
||||
{ label: 'Comedy', value: 'Comedy' },
|
||||
{ label: 'Drama', value: 'Drama' },
|
||||
{ label: 'Fantasy', value: 'Fantasy' },
|
||||
{ label: 'Harem', value: 'Harem' },
|
||||
{ label: 'Martial Arts', value: 'Martial Arts' },
|
||||
{ label: 'Modern', value: 'Modern' },
|
||||
{ label: 'Mystery', value: 'Mystery' },
|
||||
{ label: 'Psychological', value: 'Psychological' },
|
||||
{ label: 'Romance', value: 'Romance' },
|
||||
{ label: 'Slice of life', value: 'Slice of Life' },
|
||||
{ label: 'Tragedy', value: 'Tragedy' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new Genesis();
|
||||
@@ -0,0 +1,162 @@
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { load as loadCheerio } from 'cheerio';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class InkittPlugin implements Plugin.PluginBase {
|
||||
id = 'inkitt';
|
||||
name = 'Inkitt';
|
||||
icon = 'src/en/inkitt/icon.png';
|
||||
site = 'https://www.inkitt.com';
|
||||
version = '1.0.1';
|
||||
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
|
||||
|
||||
//flag indicates whether access to LocalStorage, SesesionStorage is required.
|
||||
webStorageUtilized?: boolean;
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let req;
|
||||
if (filters?.genres?.value) {
|
||||
req = await fetchApi(
|
||||
this.site +
|
||||
`/genre/${filters.genres.value}/${pageNo}?period=alltime&sort=popular`,
|
||||
);
|
||||
} else {
|
||||
req = await fetchApi(
|
||||
this.site + `/trending_stories?page=${pageNo}&period=alltime`,
|
||||
);
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = await req.json();
|
||||
} catch (e) {
|
||||
throw new Error('Failed to load novels, try opening in webview.');
|
||||
}
|
||||
|
||||
return data.stories.map((novel: any) => {
|
||||
return {
|
||||
name: novel.title,
|
||||
path: this.getPath(novel),
|
||||
cover:
|
||||
novel.vertical_cover.url ||
|
||||
novel.vertical_cover.iphone ||
|
||||
novel.cover.url,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
getPath(novel: any) {
|
||||
return (novel.category_one || novel.genres[0]) + '/' + novel.id;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const req = await fetchApi(this.site + `/stories/${novelPath}`);
|
||||
const text = await req.text();
|
||||
const loadedCheerio = loadCheerio(text);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h1.story-title').text(),
|
||||
};
|
||||
|
||||
novel.author = loadedCheerio('dl > dd > a.author-link').text();
|
||||
|
||||
novel.genres = loadedCheerio('dd.genres > a')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(', ');
|
||||
const status = loadedCheerio('div.dlc > dl:has(dt:contains("Status")) > dd')
|
||||
.text()
|
||||
.trim();
|
||||
if (status === 'Complete') novel.status = NovelStatus.Completed;
|
||||
if (status === 'Ongoing') novel.status = NovelStatus.Ongoing;
|
||||
|
||||
const apiReq = await fetchApi(
|
||||
this.site + `/api/stories/${novelPath.split('/')[1]}`,
|
||||
);
|
||||
const apiData = await apiReq.json();
|
||||
novel.cover = apiData.vertical_cover.url;
|
||||
|
||||
novel.summary = loadedCheerio('p.story-summary').text();
|
||||
|
||||
novel.chapters = apiData.chapters.map((c: any) => {
|
||||
return {
|
||||
name: c.name,
|
||||
path: novelPath + '/chapters/' + c.chapter_number,
|
||||
chapterNumber: c.chapter_number,
|
||||
};
|
||||
});
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const req = await fetchApi(this.site + '/stories/' + chapterPath);
|
||||
const text = await req.text();
|
||||
const loadedCheerio = loadCheerio(text);
|
||||
|
||||
return loadedCheerio('div#chapterText').html() || '';
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const req = await fetchApi(
|
||||
this.site +
|
||||
`/api/2/search/title?q=${encodeURIComponent(searchTerm)}&page=${pageNo}`,
|
||||
);
|
||||
let data;
|
||||
try {
|
||||
data = await req.json();
|
||||
} catch (e) {
|
||||
throw new Error('Failed to search novels, try opening in webview.');
|
||||
}
|
||||
|
||||
return data.stories.map((novel: any) => {
|
||||
return {
|
||||
name: novel.title,
|
||||
path: this.getPath(novel),
|
||||
cover:
|
||||
novel.vertical_cover.url ||
|
||||
novel.vertical_cover.iphone ||
|
||||
novel.cover.url,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
resolveUrl = (path: string, isNovel?: boolean) =>
|
||||
this.site + '/stories/' + path;
|
||||
|
||||
filters = {
|
||||
genres: {
|
||||
type: FilterTypes.Picker,
|
||||
label: 'Genre',
|
||||
value: '',
|
||||
options: [
|
||||
{ 'label': 'Sci-Fi', 'value': 'scifi' },
|
||||
{ 'label': 'Fantasy', 'value': 'fantasy' },
|
||||
{ 'label': 'Adventure', 'value': 'adventure' },
|
||||
{ 'label': 'Mystery', 'value': 'mystery' },
|
||||
{ 'label': 'Action', 'value': 'action' },
|
||||
{ 'label': 'Horror', 'value': 'horror' },
|
||||
{ 'label': 'Humor', 'value': 'humor' },
|
||||
{ 'label': 'Erotica', 'value': 'erotica' },
|
||||
{ 'label': 'Poetry', 'value': 'poetry' },
|
||||
{ 'label': 'Other', 'value': 'other' },
|
||||
{ 'label': 'Thriller', 'value': 'thriller' },
|
||||
{ 'label': 'Romance', 'value': 'romance' },
|
||||
{ 'label': 'Children', 'value': 'children' },
|
||||
{ 'label': 'Drama', 'value': 'drama' },
|
||||
],
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new InkittPlugin();
|
||||
@@ -0,0 +1,377 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class KDTNovels implements Plugin.PluginBase {
|
||||
id = 'kdtnovels';
|
||||
name = 'KDT Novels';
|
||||
version = '1.0.0';
|
||||
icon = 'src/en/kdtnovels/icon.png';
|
||||
site = 'https://kdtnovels.com/';
|
||||
|
||||
/**
|
||||
* Parse novel items from HTML using common selectors
|
||||
*/
|
||||
private parseNovelItems(html: string): Plugin.NovelItem[] {
|
||||
const $ = parseHTML(html);
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
$('div.c-tabs-item__content').each((_, element) => {
|
||||
const $element = $(element);
|
||||
|
||||
// Extract cover image
|
||||
const coverImg = $element.find('div.tab-thumb img').first();
|
||||
const cover =
|
||||
coverImg.attr('data-src') || coverImg.attr('src') || defaultCover;
|
||||
|
||||
// Extract title and URL
|
||||
const titleLink = $element.find('div.post-title > h3 > a').first();
|
||||
const name = titleLink.text().trim();
|
||||
const href = titleLink.attr('href');
|
||||
|
||||
// Only add if we have required data
|
||||
if (name && href) {
|
||||
// Convert full URL to relative path, remove leading and trailing slashes
|
||||
const path = href.replace(this.site, '').replace(/^\/+|\/+$/g, '');
|
||||
|
||||
novels.push({
|
||||
name,
|
||||
path,
|
||||
cover: cover || defaultCover,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse status text and map it to NovelStatus enum
|
||||
*/
|
||||
private parseNovelStatus(statusText: string): string {
|
||||
if (!statusText) {
|
||||
return NovelStatus.Unknown;
|
||||
}
|
||||
|
||||
const normalizedStatus = statusText.toLowerCase().trim();
|
||||
|
||||
// Map common status patterns to NovelStatus enum values
|
||||
if (
|
||||
normalizedStatus.includes('ongoing') ||
|
||||
normalizedStatus.includes('on going')
|
||||
) {
|
||||
return NovelStatus.Ongoing;
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedStatus.includes('completed') ||
|
||||
normalizedStatus.includes('complete')
|
||||
) {
|
||||
return NovelStatus.Completed;
|
||||
}
|
||||
|
||||
if (normalizedStatus.includes('licensed')) {
|
||||
return NovelStatus.Licensed;
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedStatus.includes('finished') ||
|
||||
normalizedStatus.includes('publishing finished')
|
||||
) {
|
||||
return NovelStatus.PublishingFinished;
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedStatus.includes('cancelled') ||
|
||||
normalizedStatus.includes('canceled')
|
||||
) {
|
||||
return NovelStatus.Cancelled;
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedStatus.includes('hiatus') ||
|
||||
normalizedStatus.includes('on hiatus')
|
||||
) {
|
||||
return NovelStatus.OnHiatus;
|
||||
}
|
||||
|
||||
// If no pattern matches, return the original text or Unknown
|
||||
return statusText || NovelStatus.Unknown;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
options: Plugin.PopularNovelsOptions,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
// Construct URL based on whether we want latest or popular novels
|
||||
const orderBy = options.showLatestNovels ? 'latest' : 'views';
|
||||
const url = `${this.site}/page/${pageNo}/?s&post_type=wp-manga&m_orderby=${orderBy}`;
|
||||
|
||||
try {
|
||||
// Fetch the search results page
|
||||
const response = await fetchApi(url);
|
||||
const html = await response.text();
|
||||
|
||||
// Use common parsing method
|
||||
return this.parseNovelItems(html);
|
||||
} catch (error) {
|
||||
console.error('Error fetching popular novels:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
// Construct search URL with proper query parameters
|
||||
const url = `${this.site}/page/${pageNo}/?s=${encodeURIComponent(searchTerm)}&post_type=wp-manga&op&author&artist&release&adult`;
|
||||
|
||||
try {
|
||||
// Fetch the search results page
|
||||
const response = await fetchApi(url);
|
||||
const html = await response.text();
|
||||
|
||||
// Use common parsing method
|
||||
const novels = this.parseNovelItems(html);
|
||||
|
||||
// Handle empty search results gracefully
|
||||
if (novels.length === 0) {
|
||||
console.log(
|
||||
`No search results found for term: "${searchTerm}" on page ${pageNo}`,
|
||||
);
|
||||
}
|
||||
|
||||
return novels;
|
||||
} catch (error) {
|
||||
console.error('Error searching novels:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novelUrl = this.site + novelPath;
|
||||
|
||||
try {
|
||||
// Fetch the novel details page
|
||||
const response = await fetchApi(novelUrl);
|
||||
const html = await response.text();
|
||||
|
||||
// Parse HTML with Cheerio
|
||||
const $ = parseHTML(html);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
name: '',
|
||||
path: novelPath,
|
||||
cover: defaultCover,
|
||||
author: '',
|
||||
artist: '',
|
||||
genres: '',
|
||||
summary: '',
|
||||
status: '',
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
// Extract novel metadata using provided CSS selectors
|
||||
|
||||
// Extract novel title
|
||||
const titleElement = $('.manga-title').first();
|
||||
novel.name = titleElement.text().trim() || 'Unknown Title';
|
||||
|
||||
// Extract cover image
|
||||
const coverElement = $('div.summary_image img').first();
|
||||
const coverSrc =
|
||||
coverElement.attr('data-src') || coverElement.attr('src');
|
||||
novel.cover = coverSrc || defaultCover;
|
||||
|
||||
// Extract genres
|
||||
const genreElements = $('div.genres-content a');
|
||||
const genres: string[] = [];
|
||||
genreElements.each((_, element) => {
|
||||
const genre = $(element).text().trim();
|
||||
if (genre) {
|
||||
genres.push(genre);
|
||||
}
|
||||
});
|
||||
novel.genres = genres.join(',');
|
||||
|
||||
// Extract summary/synopsis
|
||||
const summaryElements = $('div.manga-excerpt p');
|
||||
const summaryParts: string[] = [];
|
||||
summaryElements.each((_, element) => {
|
||||
const text = $(element).text().trim();
|
||||
if (text) {
|
||||
summaryParts.push(text);
|
||||
}
|
||||
});
|
||||
novel.summary = summaryParts.join('\n\n');
|
||||
|
||||
// Extract status
|
||||
const statusElement = $('div.manga-status span:nth-child(2)').first();
|
||||
const rawStatus = statusElement.text().trim();
|
||||
novel.status = this.parseNovelStatus(rawStatus);
|
||||
|
||||
// Extract chapter list - chapters are loaded dynamically via AJAX
|
||||
let chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
try {
|
||||
// Construct AJAX URL for chapter list
|
||||
const ajaxUrl = `${novelUrl}/ajax/chapters/?t=1`;
|
||||
|
||||
// Make POST request to get chapter list HTML fragment
|
||||
const chapterResponse = await fetchApi(ajaxUrl, {
|
||||
method: 'POST',
|
||||
});
|
||||
const chapterHtml = await chapterResponse.text();
|
||||
|
||||
// Parse the HTML fragment
|
||||
const $chapters = parseHTML(chapterHtml);
|
||||
|
||||
// Extract chapters using existing parsing logic
|
||||
const chapterElements = $chapters('li.free-chap');
|
||||
|
||||
chapterElements.each((index, element) => {
|
||||
const $element = $chapters(element);
|
||||
|
||||
// Extract chapter link and name
|
||||
const chapterLink = $element.find('a').first();
|
||||
const chapterName = chapterLink.text().trim();
|
||||
const chapterHref = chapterLink.attr('href');
|
||||
|
||||
// Extract release date
|
||||
const releaseDateElement = $element
|
||||
.find('span.chapter-release-date')
|
||||
.first();
|
||||
const releaseTime = releaseDateElement.text().trim() || null;
|
||||
|
||||
// Extract chapter number from title (opportunistic parsing)
|
||||
let chapterNumber = chapterElements.length - index; // Default fallback (reverse order)
|
||||
|
||||
// Try multiple patterns for chapter number extraction (supporting decimals)
|
||||
let chapterNumberMatch = chapterName.match(/Ch\s*(\d+(?:\.\d+)?)/i);
|
||||
if (!chapterNumberMatch) {
|
||||
chapterNumberMatch = chapterName.match(/c(\d+(?:\.\d+)?)/i);
|
||||
}
|
||||
if (chapterNumberMatch) {
|
||||
chapterNumber = parseFloat(chapterNumberMatch[1]);
|
||||
}
|
||||
|
||||
if (chapterName && chapterHref) {
|
||||
// Extract just the pathname from the URL, removing leading and trailing slashes
|
||||
let chapterPath: string;
|
||||
if (chapterHref.startsWith('http')) {
|
||||
const url = new URL(chapterHref);
|
||||
chapterPath = url.pathname.replace(/^\/+|\/+$/g, '');
|
||||
} else {
|
||||
chapterPath = chapterHref.replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterPath,
|
||||
releaseTime: releaseTime,
|
||||
chapterNumber: chapterNumber,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Reverse the chapters array since they come in reverse order (latest first)
|
||||
chapters.reverse();
|
||||
} catch (chapterError) {
|
||||
console.error('Error fetching chapter list:', chapterError);
|
||||
// Fall back to empty chapters array if AJAX request fails
|
||||
chapters = [];
|
||||
}
|
||||
|
||||
novel.chapters = chapters;
|
||||
|
||||
return novel;
|
||||
} catch (error) {
|
||||
console.error('Error parsing novel:', error);
|
||||
// Return basic novel object with minimal data on error
|
||||
return {
|
||||
name: 'Error loading novel',
|
||||
path: novelPath,
|
||||
cover: defaultCover,
|
||||
chapters: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const chapterUrl = this.site + chapterPath;
|
||||
|
||||
try {
|
||||
// Implement HTTP request to fetch chapter page
|
||||
const response = await fetchApi(chapterUrl);
|
||||
const html = await response.text();
|
||||
|
||||
// Parse HTML with Cheerio
|
||||
const $ = parseHTML(html);
|
||||
|
||||
// Identify main chapter content container using CSS selectors
|
||||
const contentContainer = $('div.reading-content').first();
|
||||
|
||||
if (contentContainer.length === 0) {
|
||||
console.warn('Chapter content container not found');
|
||||
return '';
|
||||
}
|
||||
|
||||
// Remove hidden input elements used for tracking (infinite scrolling)
|
||||
contentContainer.find('input[type="hidden"]').remove();
|
||||
|
||||
// Handle embedded images - ensure they have proper attributes
|
||||
contentContainer.find('img').each((_, img) => {
|
||||
const $img = $(img);
|
||||
|
||||
// Handle lazy-loaded images by moving data-src to src if needed
|
||||
const dataSrc = $img.attr('data-src');
|
||||
if (dataSrc && !$img.attr('src')) {
|
||||
$img.attr('src', dataSrc);
|
||||
}
|
||||
|
||||
// Ensure images have alt text for accessibility
|
||||
if (!$img.attr('alt')) {
|
||||
$img.attr('alt', 'Chapter image');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle special formatting elements - preserve paragraph structure
|
||||
// Ensure proper spacing between paragraphs and other block elements
|
||||
contentContainer.find('p, div, br').each((_, element) => {
|
||||
const $element = $(element);
|
||||
|
||||
// Ensure paragraphs have proper spacing
|
||||
if (element.tagName?.toLowerCase() === 'p' && $element.text().trim()) {
|
||||
// Paragraph already has proper HTML structure
|
||||
}
|
||||
|
||||
// Handle div elements that might contain text
|
||||
if (
|
||||
element.tagName?.toLowerCase() === 'div' &&
|
||||
$element.text().trim()
|
||||
) {
|
||||
// Preserve div structure for special formatting
|
||||
}
|
||||
});
|
||||
|
||||
// Extract the HTML content while preserving formatting
|
||||
let chapterContent = contentContainer.html();
|
||||
|
||||
// Filter out HTML comments
|
||||
if (chapterContent) {
|
||||
chapterContent = chapterContent.replace(/<!--[\s\S]*?-->/g, '');
|
||||
}
|
||||
|
||||
// Return properly formatted HTML string
|
||||
return chapterContent?.trim() || '';
|
||||
} catch (error) {
|
||||
console.error('Error parsing chapter:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new KDTNovels();
|
||||
@@ -0,0 +1,499 @@
|
||||
import { Parser } from 'htmlparser2';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class LightNovelPub implements Plugin.PagePlugin {
|
||||
id = 'lightnovelpub';
|
||||
name = 'LightNovelPub';
|
||||
version = '2.2.0';
|
||||
icon = 'src/en/lightnovelpub/icon.png';
|
||||
site = 'https://www.lightnovelpub.com/';
|
||||
headers = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
parseNovels(html: string) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
let tempNovel: Partial<Plugin.NovelItem> = {};
|
||||
let state: ParsingState = ParsingState.Idle;
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (attribs['class'] === 'novel-item') {
|
||||
state = ParsingState.Novel;
|
||||
}
|
||||
if (state !== ParsingState.Novel) return;
|
||||
|
||||
switch (name) {
|
||||
case 'a':
|
||||
tempNovel.path = attribs['href'].slice(1);
|
||||
tempNovel.name = attribs['title'];
|
||||
break;
|
||||
case 'img':
|
||||
tempNovel.cover = attribs['data-src'] || attribs['src'];
|
||||
break;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'li') {
|
||||
if (tempNovel.path && tempNovel.cover) {
|
||||
novels.push(tempNovel as Plugin.NovelItem);
|
||||
tempNovel = {};
|
||||
}
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const linkParts = [
|
||||
this.site + 'browse',
|
||||
filters.genres.value,
|
||||
filters.order.value,
|
||||
filters.status.value,
|
||||
page.toString(),
|
||||
];
|
||||
|
||||
const body = await fetchApi(linkParts.join('/')).then(r => r.text());
|
||||
|
||||
return this.parseNovels(body);
|
||||
}
|
||||
|
||||
async parseNovel(
|
||||
novelPath: string,
|
||||
): Promise<Plugin.SourceNovel & { totalPages: number }> {
|
||||
const body = await fetchApi(this.site + novelPath).then(r => r.text());
|
||||
const novel: Partial<Plugin.SourceNovel> & Partial<{ totalPages: number }> =
|
||||
{
|
||||
path: novelPath,
|
||||
chapters: [],
|
||||
};
|
||||
let state: ParsingState = ParsingState.Idle;
|
||||
const summaryParts: string[] = [];
|
||||
const genreArray: string[] = [];
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
switch (name) {
|
||||
case 'h1':
|
||||
if (attribs['class']?.includes('novel-title')) {
|
||||
state = ParsingState.NovelName;
|
||||
}
|
||||
break;
|
||||
case 'figure':
|
||||
if (attribs['class'] === 'cover') {
|
||||
state = ParsingState.Cover;
|
||||
}
|
||||
break;
|
||||
case 'img':
|
||||
if (state === ParsingState.Cover) {
|
||||
novel.cover = attribs['data-src'] || attribs['src'];
|
||||
}
|
||||
break;
|
||||
case 'strong':
|
||||
if (state === ParsingState.HeaderStats) {
|
||||
if (attribs['class']) {
|
||||
state = ParsingState.Status;
|
||||
} else {
|
||||
state = ParsingState.TotalChapters;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'br':
|
||||
if (state === ParsingState.Summary) {
|
||||
summaryParts.push('<LINE_BREAK>');
|
||||
}
|
||||
break;
|
||||
case 'a':
|
||||
if (state === ParsingState.Genres) {
|
||||
state = ParsingState.Tags;
|
||||
}
|
||||
break;
|
||||
case 'div':
|
||||
if (attribs['class']) {
|
||||
if (attribs['class'].includes('content')) {
|
||||
state = ParsingState.Summary;
|
||||
} else {
|
||||
const map: Record<string, ParsingState> = {
|
||||
'categories': ParsingState.Genres,
|
||||
'header-stats': ParsingState.HeaderStats,
|
||||
'expand': ParsingState.Idle,
|
||||
};
|
||||
state = map[attribs['class']] ?? state;
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (attribs['itemprop'] === 'author') {
|
||||
state = ParsingState.AuthorName;
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
switch (state) {
|
||||
case ParsingState.TotalChapters:
|
||||
if (!novel.totalPages) {
|
||||
novel.totalPages = Math.ceil(parseInt(data, 10) / 100);
|
||||
}
|
||||
break;
|
||||
case ParsingState.Status:
|
||||
novel.status = data.trim();
|
||||
break;
|
||||
case ParsingState.NovelName:
|
||||
novel.name = (novel.name || '') + data.trim();
|
||||
break;
|
||||
case ParsingState.AuthorName:
|
||||
novel.author = data;
|
||||
break;
|
||||
case ParsingState.Summary:
|
||||
summaryParts.push(data);
|
||||
break;
|
||||
case ParsingState.Tags:
|
||||
genreArray.push(data);
|
||||
break;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
switch (name) {
|
||||
case 'strong':
|
||||
if (state === ParsingState.TotalChapters) {
|
||||
state = ParsingState.HeaderStats;
|
||||
} else if (state === ParsingState.Status) {
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
break;
|
||||
case 'i':
|
||||
if (state === ParsingState.Status) {
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
break;
|
||||
case 'h1':
|
||||
if (state === ParsingState.NovelName) {
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
break;
|
||||
case 'span':
|
||||
if (state === ParsingState.AuthorName) {
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
break;
|
||||
case 'div':
|
||||
if (
|
||||
state === ParsingState.Summary ||
|
||||
state === ParsingState.Genres
|
||||
) {
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
break;
|
||||
case 'a':
|
||||
if (state === ParsingState.Tags) {
|
||||
state = ParsingState.Genres;
|
||||
}
|
||||
break;
|
||||
case 'figure':
|
||||
if (state === ParsingState.Cover) {
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
break;
|
||||
case 'p':
|
||||
if (state === ParsingState.Summary) {
|
||||
summaryParts.push('<PARAGRAPH_BREAK>');
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
onend() {
|
||||
const text = summaryParts
|
||||
.join('')
|
||||
.replace(/<PARAGRAPH_BREAK>/g, '\n\n')
|
||||
.replace(/<LINE_BREAK>/g, '\n')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/ /g, ' ');
|
||||
|
||||
const paragraphs = text
|
||||
.split('\n\n')
|
||||
.map(p => p.trim().replace(/[ \t]+/g, ' '))
|
||||
.filter(p => p.length > 0);
|
||||
|
||||
novel.summary = paragraphs.join('\n\n');
|
||||
summaryParts.length = 0;
|
||||
|
||||
novel.genres = genreArray.join(', ');
|
||||
},
|
||||
});
|
||||
|
||||
parser.write(body);
|
||||
parser.end();
|
||||
|
||||
return novel as Plugin.SourceNovel & { totalPages: number };
|
||||
}
|
||||
|
||||
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
|
||||
const url = this.site + novelPath + '/chapters/page-' + page;
|
||||
const body = await fetchApi(url).then(res => res.text());
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
let tempChapter: Partial<Plugin.ChapterItem> = {};
|
||||
let state: ParsingState = ParsingState.Idle;
|
||||
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (attribs['class'] === 'chapter-list') {
|
||||
state = ParsingState.ChapterList;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case ParsingState.ChapterList:
|
||||
if (name === 'li') {
|
||||
state = ParsingState.ChapterItem;
|
||||
tempChapter = {
|
||||
chapterNumber: Number(attribs['data-orderno'] || 0),
|
||||
};
|
||||
}
|
||||
break;
|
||||
case ParsingState.ChapterItem:
|
||||
switch (name) {
|
||||
case 'a':
|
||||
tempChapter.name = attribs['title'];
|
||||
tempChapter.path = attribs['href']?.slice(1);
|
||||
break;
|
||||
case 'time':
|
||||
tempChapter.releaseTime = dayjs(
|
||||
attribs['datetime'],
|
||||
).toISOString();
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
switch (state) {
|
||||
case ParsingState.ChapterItem:
|
||||
if (name === 'li') {
|
||||
if (
|
||||
tempChapter.chapterNumber !== undefined &&
|
||||
tempChapter.path &&
|
||||
tempChapter.releaseTime
|
||||
) {
|
||||
chapters.push(tempChapter as Plugin.ChapterItem);
|
||||
}
|
||||
state = ParsingState.ChapterList;
|
||||
}
|
||||
break;
|
||||
case ParsingState.ChapterList:
|
||||
if (name === 'ul') {
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
parser.write(body);
|
||||
parser.end();
|
||||
|
||||
return { chapters };
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const html = await fetchApi(this.site + chapterPath).then(r => r.text());
|
||||
|
||||
let depth = 0;
|
||||
let state: ParsingState = ParsingState.Idle;
|
||||
const chapterHtml: string[] = [];
|
||||
|
||||
type EscapeChar = '&' | '<' | '>' | '"' | "'";
|
||||
const escapeRegex = /[&<>"']/g;
|
||||
const escapeMap: Record<EscapeChar, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
const escapeHtml = (text: string): string =>
|
||||
text.replace(escapeRegex, char => escapeMap[char as EscapeChar]);
|
||||
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
switch (state) {
|
||||
case ParsingState.Idle:
|
||||
if (name === 'div' && attribs['id'] === 'chapter-container') {
|
||||
state = ParsingState.Chapter;
|
||||
depth++;
|
||||
}
|
||||
break;
|
||||
case ParsingState.Chapter:
|
||||
if (name === 'div') depth++;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === ParsingState.Chapter) {
|
||||
const attr = Object.keys(attribs).map(key => {
|
||||
const value = attribs[key].replace(/"/g, '"');
|
||||
return ` ${key}="${value}"`;
|
||||
});
|
||||
chapterHtml.push(`<${name}${attr.join('')}>`);
|
||||
}
|
||||
},
|
||||
|
||||
ontext(text) {
|
||||
if (state === ParsingState.Chapter) {
|
||||
chapterHtml.push(escapeHtml(text));
|
||||
}
|
||||
},
|
||||
|
||||
onclosetag(name) {
|
||||
if (state === ParsingState.Chapter) {
|
||||
if (!parser['isVoidElement'](name)) {
|
||||
chapterHtml.push(`</${name}>`);
|
||||
}
|
||||
if (name === 'div') depth--;
|
||||
if (depth === 0) {
|
||||
state = ParsingState.Stopped;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
|
||||
return chapterHtml.join('');
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}lnsearchlive`;
|
||||
const link = `${this.site}search`;
|
||||
const response = await fetchApi(link).then(r => r.text());
|
||||
let verifytoken = '';
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (
|
||||
name === 'input' &&
|
||||
attribs['name']?.includes('LNRequestVerifyToken')
|
||||
) {
|
||||
verifytoken = attribs['value'];
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(response);
|
||||
parser.end();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('inputContent', searchTerm);
|
||||
|
||||
const body = await fetchApi(url, {
|
||||
method: 'POST',
|
||||
headers: { LNRequestVerifyToken: verifytoken! },
|
||||
body: formData,
|
||||
}).then(r => r.json());
|
||||
|
||||
return this.parseNovels(body.resultview);
|
||||
}
|
||||
|
||||
filters = {
|
||||
order: {
|
||||
value: 'popular',
|
||||
label: 'Order by',
|
||||
options: [
|
||||
{ label: 'New', value: 'new' },
|
||||
{ label: 'Popular', value: 'popular' },
|
||||
{ label: 'Updates', value: 'updated' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
status: {
|
||||
value: 'all',
|
||||
label: 'Status',
|
||||
options: [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Completed', value: 'completed' },
|
||||
{ label: 'Ongoing', value: 'ongoing' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genres: {
|
||||
value: 'all',
|
||||
label: 'Genre',
|
||||
options: [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Action', value: 'action' },
|
||||
{ label: 'Adventure', value: 'adventure' },
|
||||
{ label: 'Drama', value: 'drama' },
|
||||
{ label: 'Fantasy', value: 'fantasy' },
|
||||
{ label: 'Harem', value: 'harem' },
|
||||
{ label: 'Martial Arts', value: 'martial-arts' },
|
||||
{ label: 'Mature', value: 'mature' },
|
||||
{ label: 'Romance', value: 'romance' },
|
||||
{ label: 'Tragedy', value: 'tragedy' },
|
||||
{ label: 'Xuanhuan', value: 'xuanhuan' },
|
||||
{ label: 'Ecchi', value: 'ecchi' },
|
||||
{ label: 'Comedy', value: 'comedy' },
|
||||
{ label: 'Slice of Life', value: 'slice-of-life' },
|
||||
{ label: 'Mystery', value: 'mystery' },
|
||||
{ label: 'Supernatural', value: 'supernatural' },
|
||||
{ label: 'Psychological', value: 'psychological' },
|
||||
{ label: 'Sci-fi', value: 'sci-fi' },
|
||||
{ label: 'Xianxia', value: 'xianxia' },
|
||||
{ label: 'School Life', value: 'school-life' },
|
||||
{ label: 'Josei', value: 'josei' },
|
||||
{ label: 'Wuxia', value: 'wuxia' },
|
||||
{ label: 'Shounen', value: 'shounen' },
|
||||
{ label: 'Horror', value: 'horror' },
|
||||
{ label: 'Mecha', value: 'mecha' },
|
||||
{ label: 'Historical', value: 'historical' },
|
||||
{ label: 'Shoujo', value: 'shoujo' },
|
||||
{ label: 'Adult', value: 'adult' },
|
||||
{ label: 'Seinen', value: 'seinen' },
|
||||
{ label: 'Sports', value: 'sports' },
|
||||
{ label: 'Lolicon', value: 'lolicon' },
|
||||
{ label: 'Gender Bender', value: 'gender-bender' },
|
||||
{ label: 'Shounen Ai', value: 'shounen-ai' },
|
||||
{ label: 'Yaoi', value: 'yaoi' },
|
||||
{ label: 'Video Games', value: 'video-games' },
|
||||
{ label: 'Smut', value: 'smut' },
|
||||
{ label: 'Magical Realism', value: 'magical-realism' },
|
||||
{ label: 'Eastern Fantasy', value: 'eastern-fantasy' },
|
||||
{ label: 'Contemporary Romance', value: 'contemporary-romance' },
|
||||
{ label: 'Fantasy Romance', value: 'fantasy-romance' },
|
||||
{ label: 'Shoujo Ai', value: 'shoujo-ai' },
|
||||
{ label: 'Yuri', value: 'yuri' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new LightNovelPub();
|
||||
|
||||
enum ParsingState {
|
||||
Idle,
|
||||
Novel,
|
||||
HeaderStats,
|
||||
Status,
|
||||
Stopped,
|
||||
Chapter,
|
||||
ChapterItem,
|
||||
ChapterList,
|
||||
TotalChapters,
|
||||
NovelName,
|
||||
AuthorName,
|
||||
Summary,
|
||||
Genres,
|
||||
Tags,
|
||||
Cover,
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { fetchApi, fetchProto, fetchText } from '@/lib/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
import { load as loadCheerio } from 'cheerio';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
// import { isUrlAbsolute } from '@libs/isAbsoluteUrl';
|
||||
// import { storage, localStorage, sessionStorage } from '@/lib/storage';
|
||||
// import { encode, decode } from 'urlencode';
|
||||
// import dayjs from 'dayjs';
|
||||
import { Parser } from 'htmlparser2';
|
||||
|
||||
class LNTPlugin implements Plugin.PluginBase {
|
||||
id = 'lightnoveltranslations';
|
||||
name = 'Light Novel Translations';
|
||||
icon = 'src/en/lightnoveltranslations/icon.png';
|
||||
site = 'https://lightnovelstranslations.com/';
|
||||
version = '1.0.0';
|
||||
filters: Filters | undefined = undefined;
|
||||
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
|
||||
|
||||
//flag indicates whether access to LocalStorage, SesesionStorage is required.
|
||||
webStorageUtilized?: boolean;
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = this.site + 'read/';
|
||||
link += `page/${pageNo}`;
|
||||
link += `?sortby=${showLatestNovels ? 'most-recent' : 'most-liked'}`;
|
||||
|
||||
const body = await fetchApi(link);
|
||||
const html = await body.text();
|
||||
|
||||
const loadedCheerio = loadCheerio(html);
|
||||
|
||||
const baseUrl = this.site;
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
loadedCheerio('div.read_list-story-item').each((i, el) => {
|
||||
const tempNovel = {} as Plugin.NovelItem;
|
||||
const img = loadedCheerio(el)
|
||||
.find('.item_thumb')
|
||||
.find('img')
|
||||
.first()
|
||||
.attr('src');
|
||||
let path = loadedCheerio(el)
|
||||
.find('.item_thumb')
|
||||
.find('a')
|
||||
.first()
|
||||
.attr('href');
|
||||
path = path ? path.slice(baseUrl.length) : '';
|
||||
const title = loadedCheerio(el)
|
||||
.find('.item_thumb')
|
||||
.find('a')
|
||||
.first()
|
||||
.attr('title');
|
||||
tempNovel.name = title ? title : '';
|
||||
tempNovel.path = path;
|
||||
tempNovel.cover = img;
|
||||
novels.push(tempNovel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const url = this.site + novelPath;
|
||||
const body = await fetchApi(url);
|
||||
const html = await body.text().then(r => r.replace(/>\s+</g, '><'));
|
||||
|
||||
const novel: Plugin.SourceNovel & { totalPages: number } = {
|
||||
path: novelPath,
|
||||
name: '',
|
||||
totalPages: 1,
|
||||
summary: '',
|
||||
author: '',
|
||||
status: '',
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
const loadedCheerio = loadCheerio(html);
|
||||
novel.cover = loadedCheerio('div.novel-image').find('img').attr('src');
|
||||
novel.status = loadedCheerio('div.novel_status').text().trim();
|
||||
switch (novel.status) {
|
||||
case 'Ongoing':
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
break;
|
||||
case 'Hiatus':
|
||||
novel.status = NovelStatus.OnHiatus;
|
||||
break;
|
||||
case 'Completed':
|
||||
novel.status = NovelStatus.Completed;
|
||||
break;
|
||||
default:
|
||||
novel.status = NovelStatus.Unknown;
|
||||
}
|
||||
novel.name = loadedCheerio('div.novel_title')
|
||||
.find('h3')
|
||||
.first()
|
||||
.text()
|
||||
.trim();
|
||||
novel.author = loadedCheerio('div.novel_detail_info')
|
||||
.find('li')
|
||||
.filter(function () {
|
||||
return loadedCheerio(this).text().includes('Author');
|
||||
})
|
||||
.first()
|
||||
.text()
|
||||
.trim();
|
||||
|
||||
const body2 = await fetchApi(url.replace('?tab=table_contents', ''));
|
||||
const html2 = await body2.text().then(r => r.replace(/>\s+</g, '><'));
|
||||
const loadedCheerio2 = loadCheerio(html2);
|
||||
|
||||
novel.summary = loadedCheerio2('div.novel_text')
|
||||
.find('p')
|
||||
.first()
|
||||
.text()
|
||||
.trim();
|
||||
|
||||
const baseUrl = this.site;
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('li.chapter-item.unlock').each((i, el) => {
|
||||
const chapterName = loadedCheerio(el).find('a').text().trim();
|
||||
const chapterPath = loadedCheerio(el).find('a').attr('href');
|
||||
if (chapterPath) {
|
||||
const chapter: Plugin.ChapterItem = {
|
||||
name: chapterName,
|
||||
path: chapterPath.slice(baseUrl.length),
|
||||
};
|
||||
chapters.push(chapter);
|
||||
}
|
||||
});
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
// parse chapter text here
|
||||
const body = await fetchApi(this.site + chapterPath);
|
||||
const html = await body.text().then(r => r.replace(/>\s+</g, '><'));
|
||||
|
||||
const loadedCheerio = loadCheerio(html);
|
||||
|
||||
const chapterText = loadedCheerio('div.text_story');
|
||||
chapterText.find('div.ads_content').remove();
|
||||
|
||||
return chapterText.html() || '';
|
||||
}
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
// get novels using the search term
|
||||
|
||||
if (pageNo !== 1) return [];
|
||||
const searchUrl = this.site + '/read';
|
||||
const formData = new FormData();
|
||||
formData.append('field-search', searchTerm);
|
||||
|
||||
const results = await fetchApi(searchUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const body = await results.text();
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
loadedCheerio('div.read_list-story-item').each((i, el) => {
|
||||
const tempNovel = {} as Plugin.NovelItem;
|
||||
const img = loadedCheerio(el)
|
||||
.find('.item_thumb')
|
||||
.find('img')
|
||||
.first()
|
||||
.attr('src');
|
||||
let path = loadedCheerio(el)
|
||||
.find('.item_thumb')
|
||||
.find('a')
|
||||
.first()
|
||||
.attr('href');
|
||||
path = path ? path.slice(this.site.length) : '';
|
||||
const title = loadedCheerio(el)
|
||||
.find('.item_thumb')
|
||||
.find('a')
|
||||
.first()
|
||||
.attr('title');
|
||||
tempNovel.name = title ? title : '';
|
||||
tempNovel.path = path;
|
||||
tempNovel.cover = img;
|
||||
novels.push(tempNovel);
|
||||
});
|
||||
|
||||
type SearchEntry = {
|
||||
title: string;
|
||||
thumbnail: string;
|
||||
permalink: string;
|
||||
};
|
||||
return novels;
|
||||
}
|
||||
|
||||
resolveUrl = (path: string, isNovel?: boolean) =>
|
||||
this.site + (isNovel ? '/book/' : '/chapter/') + path;
|
||||
}
|
||||
|
||||
export default new LNTPlugin();
|
||||
@@ -0,0 +1,435 @@
|
||||
import { Parser } from 'htmlparser2';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
|
||||
class LnMTLPlugin implements Plugin.PagePlugin {
|
||||
id = 'lnmtl';
|
||||
name = 'LnMTL';
|
||||
icon = 'src/en/lnmtl/icon.png';
|
||||
site = 'https://lnmtl.com/';
|
||||
version = '2.1.0';
|
||||
|
||||
async sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const params = new URLSearchParams({
|
||||
orderBy: filters.order.value,
|
||||
order: filters.sort.value,
|
||||
filter: filters.storyStatus.value,
|
||||
page: page.toString(),
|
||||
});
|
||||
|
||||
const link = `${this.site}novel?${params.toString()}`;
|
||||
const response = await fetchApi(link);
|
||||
const html = await response.text();
|
||||
const baseUrl = this.site;
|
||||
let state: ParsingState = ParsingState.Idle;
|
||||
let tempNovel: Partial<Plugin.NovelItem> = {};
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (attribs['class']?.includes('media-left')) {
|
||||
state = ParsingState.Novel;
|
||||
}
|
||||
if (state !== ParsingState.Novel) return;
|
||||
|
||||
switch (name) {
|
||||
case 'a':
|
||||
tempNovel.path = attribs['href'].replace(baseUrl, '');
|
||||
break;
|
||||
case 'img':
|
||||
tempNovel.name = attribs['alt'];
|
||||
tempNovel.cover = attribs['src'];
|
||||
break;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'div') {
|
||||
if (tempNovel.path && tempNovel.name) {
|
||||
novels.push(tempNovel as Plugin.NovelItem);
|
||||
tempNovel = {};
|
||||
}
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(
|
||||
novelPath: string,
|
||||
): Promise<Plugin.SourceNovel & { totalPages: number }> {
|
||||
const body = await fetchApi(this.site + novelPath);
|
||||
const html = await body.text().then(r => r.replace(/>\s+</g, '><'));
|
||||
|
||||
const novel: Partial<Plugin.SourceNovel> & { totalPages: number } = {
|
||||
path: novelPath,
|
||||
totalPages: 1,
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
let state = ParsingState.Idle;
|
||||
let panelValueCount = 0;
|
||||
let listCount = 0;
|
||||
let isAuthorKey = false;
|
||||
let isStatusKey = false;
|
||||
const summaryParts: string[] = [];
|
||||
const genreArray: string[] = [];
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
switch (name) {
|
||||
case 'img':
|
||||
if (attribs['class']?.includes('img-rounded')) {
|
||||
novel.name = attribs['title'];
|
||||
novel.cover = attribs['src'];
|
||||
}
|
||||
break;
|
||||
case 'dt':
|
||||
state = ParsingState.InPanelKey;
|
||||
break;
|
||||
case 'dd':
|
||||
state = ParsingState.InPanelValue;
|
||||
panelValueCount++;
|
||||
break;
|
||||
case 'ul':
|
||||
if (attribs['class']?.includes('list-inline')) {
|
||||
listCount++;
|
||||
}
|
||||
break;
|
||||
case 'li':
|
||||
if (listCount === 1) {
|
||||
state = ParsingState.InGenres;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (attribs['class']) {
|
||||
const map: Record<string, ParsingState> = {
|
||||
description: ParsingState.InDescription,
|
||||
source: ParsingState.InSource,
|
||||
progress: ParsingState.Idle,
|
||||
};
|
||||
state = map[attribs['class']] ?? state;
|
||||
}
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
switch (state) {
|
||||
case ParsingState.InScript:
|
||||
const volume = JSON.parse(
|
||||
data.match(/lnmtl.volumes = (.+])(?=;)/)![1] || '',
|
||||
);
|
||||
novel.totalPages = volume.length;
|
||||
break;
|
||||
case ParsingState.InDescription:
|
||||
summaryParts.push(data.trim());
|
||||
summaryParts.push('\n\n');
|
||||
break;
|
||||
case ParsingState.InSource:
|
||||
summaryParts.push(data);
|
||||
break;
|
||||
case ParsingState.InPanelKey:
|
||||
switch (data) {
|
||||
case 'Authors':
|
||||
isAuthorKey = true;
|
||||
break;
|
||||
case 'Current status':
|
||||
isStatusKey = true;
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case ParsingState.InPanelValue:
|
||||
if (isAuthorKey && panelValueCount === 1) {
|
||||
novel.author = (novel.author || '') + data.trim();
|
||||
isAuthorKey = false;
|
||||
} else if (isStatusKey && panelValueCount === 2) {
|
||||
novel.status = (novel.status || '') + data.trim();
|
||||
isStatusKey = false;
|
||||
}
|
||||
break;
|
||||
case ParsingState.InGenres:
|
||||
genreArray.push(data.trim());
|
||||
break;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
switch (name) {
|
||||
case 'ul':
|
||||
if (state === ParsingState.InGenres) {
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
break;
|
||||
case 'main':
|
||||
state = ParsingState.InScript;
|
||||
break;
|
||||
case 'script':
|
||||
if (state === ParsingState.InScript) {
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
onend() {
|
||||
novel.summary = summaryParts.join('');
|
||||
novel.genres = genreArray.join(', ');
|
||||
},
|
||||
});
|
||||
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
|
||||
return novel as Plugin.SourceNovel & { totalPages: number };
|
||||
}
|
||||
|
||||
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
|
||||
const result = await fetchApi(this.site + novelPath);
|
||||
const html = await result.text().then(r => r.replace(/>\s+</g, '><'));
|
||||
let state: ParsingState = ParsingState.Idle;
|
||||
let volume: VolumeEntry = {
|
||||
id: '',
|
||||
title: '',
|
||||
};
|
||||
const parser = new Parser({
|
||||
ontext(data) {
|
||||
if (state === ParsingState.InScript) {
|
||||
const volumes = JSON.parse(
|
||||
data.match(/lnmtl.volumes = (.+])(?=;)/)![1] || '',
|
||||
);
|
||||
volume = volumes[+page - 1];
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'main') {
|
||||
state = ParsingState.InScript;
|
||||
}
|
||||
if (name === 'script') {
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
await this.sleep(1000);
|
||||
const volumeData = await fetchApi(
|
||||
`${this.site}chapter?volumeId=${volume.id}`,
|
||||
);
|
||||
const volumePage = await volumeData.json();
|
||||
const firstPage = volumePage.data.map((chapter: ChapterEntry) => ({
|
||||
name: `#${chapter.number} - ${chapter.title}`,
|
||||
path: `chapter/${chapter.slug}`,
|
||||
releaseTime: new Date(chapter.created_at).toISOString(), //converts time obtained to UTC +0, TODO: Make it not convert
|
||||
}));
|
||||
chapter.push(...firstPage);
|
||||
|
||||
for (let i = 2; i <= volumePage.last_page; i++) {
|
||||
await this.sleep(1000);
|
||||
const chapterData = await fetchApi(
|
||||
`${this.site}chapter?page=${i}&volumeId=${volume.id}`,
|
||||
);
|
||||
const chapterInfo = await chapterData.json();
|
||||
|
||||
const chapterDetails = chapterInfo.data.map((chapter: ChapterEntry) => ({
|
||||
name: `#${chapter.number} ${chapter.title}`,
|
||||
path: `chapter/${chapter.slug}`,
|
||||
releaseTime: new Date(chapter.created_at).toISOString(), //converts time obtained to UTC +0, TODO: Make it not convert
|
||||
}));
|
||||
|
||||
chapter.push(...chapterDetails);
|
||||
}
|
||||
const chapters = chapter;
|
||||
return { chapters };
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(this.site + chapterPath);
|
||||
const html = await result.text();
|
||||
|
||||
let state: ParsingState = ParsingState.Idle;
|
||||
const chapterTextParts: string[] = [];
|
||||
|
||||
type EscapeChar = '&' | '<' | '>' | '"' | "'";
|
||||
const escapeRegex = /[&<>"']/g;
|
||||
const escapeMap: Record<EscapeChar, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
const escapeHtml = (text: string): string =>
|
||||
escapeRegex.test(text)
|
||||
? ((escapeRegex.lastIndex = 0),
|
||||
text.replace(escapeRegex, char => escapeMap[char as EscapeChar]))
|
||||
: text;
|
||||
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (name === 'sentence' && attribs['class']?.includes('translated')) {
|
||||
state = ParsingState.Chapter;
|
||||
chapterTextParts.push('<p>');
|
||||
}
|
||||
},
|
||||
onopentagname(name) {
|
||||
if (name === 'nav') {
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (state === ParsingState.Chapter) {
|
||||
chapterTextParts.push(escapeHtml(data));
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'sentence' && state === ParsingState.Chapter) {
|
||||
chapterTextParts.push('</p>');
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
const chapterText = chapterTextParts.join('');
|
||||
return chapterText.replace(/„/g, '“');
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const html = await fetchApi(this.site)
|
||||
.then(b => b.text())
|
||||
.then(r => r.replace(/>\s+</g, '><'));
|
||||
|
||||
let state = ParsingState.Idle;
|
||||
const listPart = new Set<string>();
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (
|
||||
state === ParsingState.InFooter &&
|
||||
name === 'script' &&
|
||||
attribs['type']?.includes('application/javascript')
|
||||
) {
|
||||
state = ParsingState.InScript;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (state === ParsingState.InScript) {
|
||||
const match = data.match(/prefetch: '\/(.*?\.json)/);
|
||||
if (match) {
|
||||
listPart.add(match[1]);
|
||||
}
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
switch (name) {
|
||||
case 'footer':
|
||||
state = ParsingState.InFooter;
|
||||
break;
|
||||
case 'script':
|
||||
if (state === ParsingState.InScript) {
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
|
||||
const search = await fetchApi(
|
||||
`${this.site}${Array.from(listPart).join('')}`,
|
||||
);
|
||||
const data = await search.json();
|
||||
|
||||
const nov = data.filter((res: { name: string }) =>
|
||||
res.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
nov.map((res: { name: string; slug: string; image: string }) => {
|
||||
const novelName = res.name;
|
||||
const novelUrl = `novel/${res.slug}`;
|
||||
const novelCover = res.image;
|
||||
|
||||
const novel = {
|
||||
path: novelUrl,
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
};
|
||||
novels.push(novel);
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
order: {
|
||||
value: 'favourites',
|
||||
label: 'Order by',
|
||||
options: [
|
||||
{ label: 'Favourites', value: 'favourites' },
|
||||
{ label: 'Name', value: 'name' },
|
||||
{ label: 'Addition Date', value: 'date' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
sort: {
|
||||
value: 'desc',
|
||||
label: 'Sort by',
|
||||
options: [
|
||||
{ label: 'Descending', value: 'desc' },
|
||||
{ label: 'Ascending', value: 'asc' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
storyStatus: {
|
||||
value: 'all',
|
||||
label: 'Status',
|
||||
options: [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Ongoing', value: 'ongoing' },
|
||||
{ label: 'Finished', value: 'finished' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new LnMTLPlugin();
|
||||
|
||||
type ChapterEntry = {
|
||||
number: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type VolumeEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
enum ParsingState {
|
||||
Idle,
|
||||
InFooter,
|
||||
InScript,
|
||||
InDescription,
|
||||
InSource,
|
||||
InPanelKey,
|
||||
InPanelValue,
|
||||
InGenres,
|
||||
Novel,
|
||||
Chapter,
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
|
||||
class MTLReader implements Plugin.PluginBase {
|
||||
id = 'mtlreader';
|
||||
name = 'MTL Reader';
|
||||
version = '1.0.1';
|
||||
icon = 'src/en/mtlreader/icon.png';
|
||||
site = 'https://mtlreader.com/';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.col-md-4').each((i, el) => {
|
||||
const novelName = loadedCheerio(el).find('h5').text();
|
||||
const novelCover = loadedCheerio(el).find('img').attr('src');
|
||||
const novelUrl = loadedCheerio(el).find('h5 > a').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}novels?page=${pageNo}`;
|
||||
|
||||
const body = await fetchApi(url).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const body = await fetchApi(this.site + novelPath).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.agent-title').text().trim() || 'Untitled',
|
||||
cover: loadedCheerio('.agent-p-img > img').attr('src'),
|
||||
summary: loadedCheerio('#editdescription').text().trim(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
novel.author = loadedCheerio('i.fa-user')
|
||||
.parent()
|
||||
.text()
|
||||
.replace('Author: ', '')
|
||||
.trim();
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('tr.spaceUnder').each((i, el) => {
|
||||
const chapterName = loadedCheerio(el).find('a').text().trim();
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href');
|
||||
|
||||
if (!chapterUrl) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapter;
|
||||
|
||||
return novel;
|
||||
}
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchApi(this.site + chapterPath).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
loadedCheerio('.container ins,script,p.mtlreader').remove();
|
||||
const chapterText = loadedCheerio('.container').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const body = await fetchApi(this.site).then(r => r.text());
|
||||
const tokenCheerio = parseHTML(body);
|
||||
const token = tokenCheerio('input[name="_token"]').attr('value');
|
||||
|
||||
const searchUrl = `${this.site}search?_token=${token}&input=${encodeURIComponent(searchTerm)}`;
|
||||
const seacrhBody = await fetchApi(searchUrl).then(r => r.text());
|
||||
const loadedCheerio = parseHTML(seacrhBody);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
}
|
||||
export default new MTLReader();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,294 @@
|
||||
import { CheerioAPI, 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.3';
|
||||
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;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<Filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
// 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) {
|
||||
filters.genre.value.forEach(genre => {
|
||||
params.append('genre[]', genre.toString());
|
||||
});
|
||||
}
|
||||
params.append('q', filters.keyword.value.toString());
|
||||
params.append('page', pageNo.toString());
|
||||
|
||||
const url = `${this.site}search?${params.toString()}`;
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const result = await fetchApi(this.site + novelPath);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.name h1').text().trim() || 'Untitled',
|
||||
cover: 'https:' + loadedCheerio('.img-cover img').attr('data-src'),
|
||||
summary: loadedCheerio('.section-body.summary .content').text().trim(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
loadedCheerio('.meta.box p').each((i, el) => {
|
||||
const detailName = loadedCheerio(el).find('strong').text();
|
||||
const detail = loadedCheerio(el).find('a');
|
||||
|
||||
switch (detailName) {
|
||||
case 'Authors :':
|
||||
novel.author = detail
|
||||
.find('span')
|
||||
.map((a, ex) => loadedCheerio(ex).text())
|
||||
.toArray()
|
||||
.join(', ');
|
||||
break;
|
||||
case 'Status :':
|
||||
novel.status = detail.text();
|
||||
break;
|
||||
case 'Genres :':
|
||||
novel.genres = detail.text().trim();
|
||||
break;
|
||||
}
|
||||
});
|
||||
const novelId = loadedCheerio('script')
|
||||
.text()
|
||||
.match(/bookId = (\d+);/)![1];
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
const getChapters = async (id: string) => {
|
||||
const chapterListUrl = `${this.site}api/manga/${id}/chapters?source=detail`;
|
||||
const data = await fetchApi(chapterListUrl);
|
||||
const chapterlist = await data.text();
|
||||
|
||||
loadedCheerio = parseHTML(chapterlist);
|
||||
|
||||
loadedCheerio('li').each((i, el) => {
|
||||
const chapterName = loadedCheerio(el)
|
||||
.find('.chapter-title')
|
||||
.text()
|
||||
.trim();
|
||||
|
||||
const releaseDate = loadedCheerio(el)
|
||||
.find('.chapter-update')
|
||||
.text()
|
||||
.trim();
|
||||
|
||||
const months = [
|
||||
'jan',
|
||||
'feb',
|
||||
'mar',
|
||||
'apr',
|
||||
'may',
|
||||
'jun',
|
||||
'jul',
|
||||
'aug',
|
||||
'sep',
|
||||
'oct',
|
||||
'nov',
|
||||
'dec',
|
||||
];
|
||||
const monthsJoined = months.join('|');
|
||||
|
||||
const rx = new RegExp(
|
||||
`(${monthsJoined}) (\\d{1,2}), (\\d{4})`,
|
||||
'i',
|
||||
).exec(releaseDate);
|
||||
if (!rx) return;
|
||||
const year = +rx[3];
|
||||
const month = months.indexOf(rx[1].toLowerCase());
|
||||
const day = +rx[2];
|
||||
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href')?.slice(1);
|
||||
|
||||
if (!chapterUrl) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
releaseTime: new Date(year, month, day).toISOString(),
|
||||
path: chapterUrl,
|
||||
});
|
||||
});
|
||||
return chapter;
|
||||
};
|
||||
|
||||
novel.chapters = (await getChapters(novelId)).reverse();
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(this.site + chapterPath);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
loadedCheerio('#listen-chapter').remove();
|
||||
loadedCheerio('#google_translate_element').remove();
|
||||
|
||||
const chapterText = loadedCheerio('.chapter__content').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}search?q=${encodeURIComponent(searchTerm)}&page=${page}`;
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
orderBy: {
|
||||
value: 'views',
|
||||
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' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
keyword: {
|
||||
value: '',
|
||||
label: 'Keywords',
|
||||
type: FilterTypes.TextInput,
|
||||
},
|
||||
status: {
|
||||
value: 'all',
|
||||
label: 'Status',
|
||||
options: [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Ongoing', value: 'ongoing' },
|
||||
{ label: 'Completed', value: 'completed' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genre: {
|
||||
value: [],
|
||||
label: 'Genres (OR, not AND)',
|
||||
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: '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: '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: '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' },
|
||||
{ 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: 'Superna', value: 'superna' },
|
||||
{ label: 'Supernatural', value: 'supernatural' },
|
||||
{ label: 'System', value: 'system' },
|
||||
{ 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();
|
||||
@@ -0,0 +1,443 @@
|
||||
import { CheerioAPI, load } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
|
||||
class NovelFire implements Plugin.PluginBase {
|
||||
id = 'novelfire';
|
||||
name = 'Novel Fire';
|
||||
version = '1.0.4';
|
||||
icon = 'src/en/novelfire/icon.png';
|
||||
site = 'https://novelfire.net/';
|
||||
|
||||
async getCheerio(url: string, search: boolean): Promise<CheerioAPI> {
|
||||
const r = await fetchApi(url);
|
||||
if (!r.ok && search != true)
|
||||
throw new Error(
|
||||
'Could not reach site (' + r.status + ') try to open in webview.',
|
||||
);
|
||||
const $ = load(await r.text());
|
||||
|
||||
return $;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site + 'search-adv';
|
||||
if (showLatestNovels) {
|
||||
url += `?ctgcon=and&totalchapter=0&ratcon=min&rating=0&status=-1&sort=date&tagcon=and&page=${pageNo}`;
|
||||
} else if (filters) {
|
||||
const params = new URLSearchParams();
|
||||
for (const language of filters.language.value) {
|
||||
params.append('country_id[]', language);
|
||||
}
|
||||
params.append('ctgcon', filters.genre_operator.value);
|
||||
for (const genre of filters.genres.value) {
|
||||
params.append('categories[]', genre);
|
||||
}
|
||||
params.append('totalchapter', filters.chapters.value);
|
||||
params.append('ratcon', filters.rating_operator.value);
|
||||
params.append('rating', filters.rating.value);
|
||||
params.append('status', filters.status.value);
|
||||
params.append('sort', filters.sort.value);
|
||||
params.append('page', pageNo.toString());
|
||||
url += `?${params.toString()}`;
|
||||
} else {
|
||||
url += `?ctgcon=and&totalchapter=0&ratcon=min&rating=0&status=-1&sort=rank-top&page=${pageNo}`;
|
||||
}
|
||||
|
||||
const loadedCheerio = await this.getCheerio(url, false);
|
||||
|
||||
return loadedCheerio('.novel-item')
|
||||
.map((index, ele) => {
|
||||
const novelName =
|
||||
loadedCheerio(ele).find('.novel-title > a').attr('title') ||
|
||||
'No Title Found';
|
||||
const novelCover = loadedCheerio(ele)
|
||||
.find('.novel-cover > img')
|
||||
.attr('data-src');
|
||||
const novelPath = loadedCheerio(ele)
|
||||
.find('.novel-title > a')
|
||||
.attr('href');
|
||||
|
||||
if (!novelPath) return null;
|
||||
|
||||
return {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelPath.replace(this.site, ''),
|
||||
};
|
||||
})
|
||||
.get()
|
||||
.filter(novel => novel !== null);
|
||||
}
|
||||
|
||||
async parseChapters(
|
||||
novelPath: string,
|
||||
pages: number,
|
||||
): Promise<Plugin.ChapterItem[]> {
|
||||
const pagesArray = Array.from({ length: pages }, (_, i) => i + 1);
|
||||
const allChapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
// Function to parse a single page
|
||||
const parsePage = async (page: number) => {
|
||||
const url = `${this.site}${novelPath}/chapters?page=${page}`;
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = load(body);
|
||||
|
||||
if (loadedCheerio.text().includes('You are being rate limited')) {
|
||||
throw new NovelFireThrottlingError();
|
||||
}
|
||||
|
||||
const chapters = loadedCheerio('.chapter-list li')
|
||||
.map((index, ele) => {
|
||||
const chapterName =
|
||||
loadedCheerio(ele).find('a').attr('title') || 'No Title Found';
|
||||
const chapterPath = loadedCheerio(ele).find('a').attr('href');
|
||||
|
||||
if (!chapterPath) return null;
|
||||
|
||||
return {
|
||||
name: chapterName,
|
||||
path: chapterPath.replace(this.site, ''),
|
||||
};
|
||||
})
|
||||
.get()
|
||||
.filter(chapter => chapter !== null) as Plugin.ChapterItem[];
|
||||
|
||||
return chapters;
|
||||
};
|
||||
|
||||
// When pages > ~30, we get rate limited. To mitigate, split into chunks and retry chunk on rate limit with delay.
|
||||
const chunkSize = 5; // 5 pages per chunk was tested to be a good balance between speed and rate limiting.
|
||||
const retryCount = 10;
|
||||
const sleepTime = 3.5; // Rate limit seems to be around ~10s, so usually 3 retries should be enough for another ~30 pages.
|
||||
|
||||
const chaptersArray: Plugin.ChapterItem[][] = [];
|
||||
|
||||
for (let i = 0; i < pagesArray.length; i += chunkSize) {
|
||||
const pagesArrayChunk = pagesArray.slice(i, i + chunkSize);
|
||||
|
||||
const firstPage = pagesArrayChunk[0];
|
||||
const lastPage = pagesArrayChunk[pagesArrayChunk.length - 1];
|
||||
|
||||
let attempt = 0;
|
||||
|
||||
while (attempt < retryCount) {
|
||||
try {
|
||||
// Parse all pages in chunk in parallel
|
||||
const chaptersArrayChunk = await Promise.all(
|
||||
pagesArrayChunk.map(parsePage),
|
||||
);
|
||||
|
||||
chaptersArray.push(...chaptersArrayChunk);
|
||||
break;
|
||||
} catch (err) {
|
||||
if (err instanceof NovelFireThrottlingError) {
|
||||
attempt += 1;
|
||||
console.warn(
|
||||
`[pages=${firstPage}-${lastPage}] Novel Fire is rate limiting requests. Retry attempt ${attempt + 1} in ${sleepTime} seconds...`,
|
||||
);
|
||||
if (attempt === retryCount) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Sleep for X second before retrying
|
||||
await new Promise(resolve => setTimeout(resolve, sleepTime * 1000));
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge all chapters into a single array
|
||||
for (const chapters of chaptersArray) {
|
||||
allChapters.push(...chapters);
|
||||
}
|
||||
|
||||
return allChapters.length === 0 ? [] : allChapters;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const $ = await this.getCheerio(this.site + novelPath, false);
|
||||
const baseUrl = this.site;
|
||||
|
||||
const novel: Partial<Plugin.SourceNovel> = {
|
||||
path: novelPath,
|
||||
};
|
||||
|
||||
novel.name =
|
||||
$('.novel-title').text().trim() ??
|
||||
$('.cover > img').attr('alt') ??
|
||||
'No Titled Found';
|
||||
const coverUrl =
|
||||
$('.cover > img').attr('data-src') ?? $('.cover > img').attr('src');
|
||||
|
||||
if (coverUrl) {
|
||||
novel.cover = new URL(coverUrl, baseUrl).href;
|
||||
}
|
||||
|
||||
novel.genres = $('.categories .property-item')
|
||||
.map((i, el) => $(el).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
let summary = $('.summary .content').text().trim();
|
||||
|
||||
if (summary) {
|
||||
summary = summary.replace('Show More', '');
|
||||
novel.summary = summary;
|
||||
} else {
|
||||
novel.summary = 'No Summary Found';
|
||||
}
|
||||
|
||||
novel.author =
|
||||
$('.author .property-item > span').text() || 'No Author Found';
|
||||
|
||||
const rawStatus =
|
||||
$('.header-stats .ongoing').text() ||
|
||||
$('.header-stats .completed').text() ||
|
||||
'Unknown';
|
||||
const map: Record<string, string> = {
|
||||
ongoing: NovelStatus.Ongoing,
|
||||
hiatus: NovelStatus.OnHiatus,
|
||||
dropped: NovelStatus.Cancelled,
|
||||
cancelled: NovelStatus.Cancelled,
|
||||
completed: NovelStatus.Completed,
|
||||
unknown: NovelStatus.Unknown,
|
||||
};
|
||||
novel.status = map[rawStatus.toLowerCase()] ?? NovelStatus.Unknown;
|
||||
|
||||
const totalChapters = $('.header-stats .icon-book-open')
|
||||
.parent()
|
||||
.text()
|
||||
.trim();
|
||||
const pages = Math.ceil(parseInt(totalChapters) / 100);
|
||||
novel.chapters = await this.parseChapters(novelPath, pages);
|
||||
|
||||
return novel as Plugin.SourceNovel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const url = this.site + chapterPath;
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = load(body);
|
||||
|
||||
const bloatElements = [
|
||||
'.box-ads',
|
||||
'.box-notification',
|
||||
/^nf/, // Regular expression to match tags starting with 'nf'
|
||||
];
|
||||
bloatElements.forEach(tag => {
|
||||
if (tag instanceof RegExp) {
|
||||
loadedCheerio('*')
|
||||
.filter((_, el) =>
|
||||
tag.test(loadedCheerio(el).prop('tagName')!.toLowerCase()),
|
||||
)
|
||||
.remove();
|
||||
} else {
|
||||
loadedCheerio(tag).remove();
|
||||
}
|
||||
});
|
||||
|
||||
return loadedCheerio('#content').html()!;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}search?keyword=${encodeURIComponent(searchTerm)}&page=${page}`;
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = load(body);
|
||||
|
||||
return loadedCheerio('.novel-list.chapters .novel-item')
|
||||
.map((index, ele) => {
|
||||
const novelName =
|
||||
loadedCheerio(ele).find('a').attr('title') || 'No Title Found';
|
||||
const novelCover = loadedCheerio(ele)
|
||||
.find('.novel-cover > img')
|
||||
.attr('src');
|
||||
const novelPath = loadedCheerio(ele).find('a').attr('href');
|
||||
|
||||
if (!novelPath) return null;
|
||||
|
||||
return {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelPath.replace(this.site, ''),
|
||||
};
|
||||
})
|
||||
.get()
|
||||
.filter(novel => novel !== null);
|
||||
}
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Sort Results By',
|
||||
value: 'rank-top',
|
||||
options: [
|
||||
{ label: 'Rank (Top)', value: 'rank-top' },
|
||||
{ label: 'Rating Score (Top)', value: 'rating-score-top' },
|
||||
{ label: 'Bookmark Count (Most)', value: 'bookmark' },
|
||||
{ label: 'Review Count (Most)', value: 'review' },
|
||||
{ label: 'Title (A>Z)', value: 'abc' },
|
||||
{ label: 'Title (Z>A)', value: 'cba' },
|
||||
{ label: 'Last Updated (Newest)', value: 'date' },
|
||||
{ label: 'Chapter Count (Most)', value: 'chapter-count-most' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
status: {
|
||||
label: 'Translation Status',
|
||||
value: '-1',
|
||||
options: [
|
||||
{ label: 'All', value: '-1' },
|
||||
{ label: 'Completed', value: '1' },
|
||||
{ label: 'Ongoing', value: '0' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genre_operator: {
|
||||
label: 'Genres (And/Or)',
|
||||
value: 'and',
|
||||
options: [
|
||||
{ label: 'And', value: 'and' },
|
||||
{ label: 'Or', value: 'or' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genres: {
|
||||
label: 'Genres',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: 'Action', value: '3' },
|
||||
{ label: 'Adult', value: '28' },
|
||||
{ label: 'Adventure', value: '4' },
|
||||
{ label: 'Anime', value: '46' },
|
||||
{ label: 'Arts', value: '47' },
|
||||
{ label: 'Comedy', value: '5' },
|
||||
{ label: 'Drama', value: '24' },
|
||||
{ label: 'Eastern', value: '44' },
|
||||
{ label: 'Ecchi', value: '26' },
|
||||
{ label: 'Fan-fiction', value: '48' },
|
||||
{ label: 'Fantasy', value: '6' },
|
||||
{ label: 'Game', value: '19' },
|
||||
{ label: 'Gender Bender', value: '25' },
|
||||
{ label: 'Harem', value: '7' },
|
||||
{ label: 'Historical', value: '12' },
|
||||
{ label: 'Horror', value: '37' },
|
||||
{ label: 'Isekai', value: '49' },
|
||||
{ label: 'Josei', value: '2' },
|
||||
{ label: 'Lgbt+', value: '45' },
|
||||
{ label: 'Magic', value: '50' },
|
||||
{ label: 'Magical Realism', value: '51' },
|
||||
{ label: 'Manhua', value: '52' },
|
||||
{ label: 'Martial Arts', value: '15' },
|
||||
{ label: 'Mature', value: '8' },
|
||||
{ label: 'Mecha', value: '34' },
|
||||
{ label: 'Military', value: '53' },
|
||||
{ label: 'Modern Life', value: '54' },
|
||||
{ label: 'Movies', value: '55' },
|
||||
{ label: 'Mystery', value: '16' },
|
||||
{ label: 'Psychological', value: '9' },
|
||||
{ label: 'Realistic Fiction', value: '56' },
|
||||
{ label: 'Reincarnation', value: '43' },
|
||||
{ label: 'Romance', value: '1' },
|
||||
{ label: 'School Life', value: '21' },
|
||||
{ label: 'Sci-fi', value: '20' },
|
||||
{ label: 'Seinen', value: '10' },
|
||||
{ label: 'Shoujo', value: '38' },
|
||||
{ label: 'Shoujo Ai', value: '57' },
|
||||
{ label: 'Shounen', value: '17' },
|
||||
{ label: 'Shounen Ai', value: '39' },
|
||||
{ label: 'Slice of Life', value: '13' },
|
||||
{ label: 'Smut', value: '29' },
|
||||
{ label: 'Sports', value: '42' },
|
||||
{ label: 'Supernatural', value: '18' },
|
||||
{ label: 'System', value: '58' },
|
||||
{ label: 'Tragedy', value: '32' },
|
||||
{ label: 'Urban', value: '63' },
|
||||
{ label: 'Urban Life', value: '59' },
|
||||
{ label: 'Video Games', value: '60' },
|
||||
{ label: 'War', value: '61' },
|
||||
{ label: 'Wuxia', value: '31' },
|
||||
{ label: 'Xianxia', value: '23' },
|
||||
{ label: 'Xuanhuan', value: '22' },
|
||||
{ label: 'Yaoi', value: '14' },
|
||||
{ label: 'Yuri', value: '62' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
language: {
|
||||
label: 'Language',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: 'Chinese', value: '1' },
|
||||
{ label: 'Korean', value: '2' },
|
||||
{ label: 'Japanese', value: '3' },
|
||||
{ label: 'English', value: '4' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
rating_operator: {
|
||||
label: 'Rating (Min/Max)',
|
||||
value: 'min',
|
||||
options: [
|
||||
{ label: 'Min', value: 'min' },
|
||||
{ label: 'Max', value: 'max' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
rating: {
|
||||
label: 'Rating',
|
||||
value: '0',
|
||||
options: [
|
||||
{ label: 'All', value: '0' },
|
||||
{ label: '1', value: '1' },
|
||||
{ label: '2', value: '2' },
|
||||
{ label: '3', value: '3' },
|
||||
{ label: '4', value: '4' },
|
||||
{ label: '5', value: '5' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
chapters: {
|
||||
label: 'Chapters',
|
||||
value: '0',
|
||||
options: [
|
||||
{ label: 'All', value: '0' },
|
||||
{ label: '<50', value: '1,49' },
|
||||
{ label: '50-100', value: '50,100' },
|
||||
{ label: '100-200', value: '100,200' },
|
||||
{ label: '200-500', value: '200,500' },
|
||||
{ label: '500-1000', value: '500,1000' },
|
||||
{ label: '>1000', value: '1001,1000000' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new NovelFire();
|
||||
|
||||
// Custom error for when Novel Fire is rate limiting requests
|
||||
class NovelFireThrottlingError extends Error {
|
||||
constructor(message = 'Novel Fire is rate limiting requests') {
|
||||
super(message);
|
||||
this.name = 'NovelFireError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
|
||||
class NovelHall implements Plugin.PluginBase {
|
||||
id = 'novelhall';
|
||||
name = 'Novel Hall';
|
||||
version = '1.0.2';
|
||||
icon = 'src/en/novelhall/icon.png';
|
||||
site = 'https://novelhall.com/';
|
||||
|
||||
async popularNovels(page: number): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}all2022-${page}.html`;
|
||||
|
||||
const body = await fetchApi(url).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('li.btm').each((idx, ele) => {
|
||||
const novelName = loadedCheerio(ele).text().trim();
|
||||
const novelUrl = loadedCheerio(ele).find('a').attr('href');
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: defaultCover,
|
||||
path: novelUrl,
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const body = await fetchApi(this.site + novelPath).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.book-info > h1').text() || 'Untitled',
|
||||
cover: loadedCheerio('meta[property="og:image"]').attr('content'),
|
||||
summary: loadedCheerio('.intro').text().trim(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
loadedCheerio('.total').find('p').remove();
|
||||
novel.author = loadedCheerio('.total span:contains("Author")')
|
||||
.text()
|
||||
.replace('Author:', '')
|
||||
.trim();
|
||||
|
||||
novel.status = loadedCheerio('.total span:contains("Status")')
|
||||
.text()
|
||||
.replace('Status:', '')
|
||||
.replace('Active', 'Ongoing')
|
||||
.trim();
|
||||
|
||||
novel.genres = loadedCheerio('.total a')
|
||||
.map((a, ex) => loadedCheerio(ex).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('#morelist ul > li').each((idx, ele) => {
|
||||
const chapterName = loadedCheerio(ele).find('a').text().trim();
|
||||
const chapterUrl = loadedCheerio(ele).find('a').attr('href');
|
||||
if (!chapterUrl) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl,
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapter;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchApi(this.site + chapterPath).then(r => r.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const chapterText = loadedCheerio('.content').html() || '';
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}index.php?s=so&module=book&keyword=${encodeURIComponent(searchTerm)}`;
|
||||
const body = await fetchApi(url).then(r => r.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('table tr').each((idx, ele) => {
|
||||
const novelName = loadedCheerio(ele)
|
||||
.find('td:nth-child(2)')
|
||||
.text()
|
||||
.replace(/\t+/g, '')
|
||||
.replace(/\n/g, ' ');
|
||||
const novelUrl = loadedCheerio(ele)
|
||||
.find('td:nth-child(2) a')
|
||||
.attr('href');
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: defaultCover,
|
||||
path: novelUrl,
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new NovelHall();
|
||||
@@ -0,0 +1,219 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import dayjs from 'dayjs';
|
||||
import { storage } from '@/lib/storage';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class Novelight implements Plugin.PagePlugin {
|
||||
id = 'novelight';
|
||||
name = 'Novelight';
|
||||
version = '1.1.1';
|
||||
icon = 'src/en/novelight/icon.png';
|
||||
site = 'https://novelight.net/';
|
||||
|
||||
hideLocked = storage.get('hideLocked');
|
||||
pluginSettings = {
|
||||
hideLocked: {
|
||||
value: '',
|
||||
label: 'Hide locked chapters',
|
||||
type: 'Switch',
|
||||
},
|
||||
};
|
||||
|
||||
async popularNovels(page: number): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}catalog/?ordering=popularity&page=${page}`;
|
||||
|
||||
const body = await fetchApi(url).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('a.item').each((idx, ele) => {
|
||||
const novelName = loadedCheerio(ele).find('div.title').text().trim();
|
||||
const novelUrl = ele.attribs.href;
|
||||
const bareNovelCover = loadedCheerio(ele).find('img').attr('src');
|
||||
const novelCover = bareNovelCover
|
||||
? this.site + bareNovelCover
|
||||
: defaultCover;
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover ?? defaultCover,
|
||||
path: novelUrl.replace('/', ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(
|
||||
novelPath: string,
|
||||
): Promise<Plugin.SourceNovel & { totalPages: number }> {
|
||||
const body = await fetchApi(this.site + novelPath).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel & { totalPages: number } = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h1').text() || 'Untitled',
|
||||
cover: this.site + loadedCheerio('.poster > img').attr('src'),
|
||||
summary: loadedCheerio('section.text-info.section > p').text(),
|
||||
totalPages: loadedCheerio('#select-pagination-chapter > option').length,
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
const info = loadedCheerio('div.mini-info > .item').toArray();
|
||||
let status = '';
|
||||
let translation = '';
|
||||
for (const child of info) {
|
||||
const type = loadedCheerio(child).find('.sub-header').text().trim();
|
||||
if (type === 'Status') {
|
||||
status = loadedCheerio(child).find('div.info').text().trim();
|
||||
}
|
||||
if (type === 'Translation') {
|
||||
translation = loadedCheerio(child).find('div.info').text().trim();
|
||||
}
|
||||
if (type === 'Author') {
|
||||
novel.author = loadedCheerio(child).find('div.info').text().trim();
|
||||
}
|
||||
if (type === 'Genres') {
|
||||
novel.genres = loadedCheerio(child)
|
||||
.find('div.info > a')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(', ');
|
||||
}
|
||||
}
|
||||
if (status === 'cancelled') novel.status = NovelStatus.Cancelled;
|
||||
else if (status === 'releasing' || translation === 'ongoing')
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
else if (status === 'completed' && translation === 'completed')
|
||||
novel.status = NovelStatus.Completed;
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
|
||||
const rawBody = await fetchApi(this.site + novelPath).then(r => r.text());
|
||||
const csrftoken = rawBody?.match(/window\.CSRF_TOKEN = "([^"]+)"/)?.[1];
|
||||
const bookId = rawBody?.match(/const OBJECT_BY_COMMENT = ([0-9]+)/)?.[1];
|
||||
const totalPages = parseInt(
|
||||
rawBody
|
||||
?.match(/<option value="([0-9]+)"/g)
|
||||
?.at(-1)
|
||||
?.match(/([0-9]+)/)?.[1] ?? '1',
|
||||
);
|
||||
|
||||
const chaptersRaw = await fetchApi(
|
||||
`${this.site}/book/ajax/chapter-pagination?csrfmiddlewaretoken=${csrftoken}&book_id=${bookId}&page=${totalPages - parseInt(page) + 1}`,
|
||||
{
|
||||
headers: {
|
||||
'Host': this.site.replace('https://', '').replace('/', ''),
|
||||
'Referer': this.site + novelPath,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
},
|
||||
)
|
||||
.then(r => r.json())
|
||||
.then(r => r.html);
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
parseHTML('<html>' + chaptersRaw + '</html>')('a').each((idx, ele) => {
|
||||
const title = parseHTML(ele)('.title').text().trim();
|
||||
const isLocked = !!parseHTML(ele)('.cost').text().trim();
|
||||
if (this.hideLocked && isLocked) return;
|
||||
|
||||
let date;
|
||||
try {
|
||||
date = dayjs(
|
||||
parseHTML(ele)('.date').text().trim(),
|
||||
'DD.MM.YYYY',
|
||||
).toISOString();
|
||||
} catch (error) {}
|
||||
|
||||
const chapterName = isLocked ? '🔒 ' + title : title;
|
||||
const chapterUrl = ele.attribs.href;
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl,
|
||||
page: page,
|
||||
releaseTime: date,
|
||||
});
|
||||
});
|
||||
|
||||
const chapters = chapter.reverse();
|
||||
return { chapters };
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const rawBody = await fetchApi(this.site + chapterPath).then(r => {
|
||||
const res = r.text();
|
||||
return res;
|
||||
});
|
||||
|
||||
const csrftoken = rawBody?.match(/window\.CSRF_TOKEN = "([^"]+)"/)?.[1];
|
||||
const chapterId = rawBody?.match(/const CHAPTER_ID = "([0-9]+)/)?.[1];
|
||||
|
||||
let className;
|
||||
const body = await fetchApi(
|
||||
this.site + 'book/ajax/read-chapter/' + chapterId,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Cookie: 'csrftoken=' + csrftoken,
|
||||
Referer: this.site + chapterPath,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
},
|
||||
).then(async r => {
|
||||
const res = await r.json();
|
||||
className = res.class;
|
||||
return res.content;
|
||||
});
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const chapterText = loadedCheerio('.' + className).html() || '';
|
||||
|
||||
return chapterText.replace(
|
||||
/class="advertisment"/g,
|
||||
'style="display:none;"',
|
||||
);
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}catalog/?search=${encodeURIComponent(searchTerm)}`;
|
||||
const body = await fetchApi(url).then(r => r.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('a.item').each((idx, ele) => {
|
||||
const novelName = loadedCheerio(ele).find('div.title').text().trim();
|
||||
const novelUrl = ele.attribs.href;
|
||||
const bareNovelCover = loadedCheerio(ele).find('img').attr('src');
|
||||
const novelCover = bareNovelCover
|
||||
? this.site + bareNovelCover
|
||||
: defaultCover;
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover ?? defaultCover,
|
||||
path: novelUrl.replace('/', ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new Novelight();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,447 @@
|
||||
import { Parser } from 'htmlparser2';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
|
||||
class PawRead implements Plugin.PluginBase {
|
||||
id = 'pawread';
|
||||
name = 'PawRead';
|
||||
version = '2.1.0';
|
||||
icon = 'src/en/pawread/icon.png';
|
||||
site = 'https://m.pawread.com/';
|
||||
|
||||
parseNovels(html: string) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
let tempNovel: Partial<Plugin.NovelItem> = {};
|
||||
let state: ParsingState = ParsingState.Idle;
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (
|
||||
attribs.class &&
|
||||
(attribs.class.includes('list-comic') ||
|
||||
attribs.class.includes('itemBox'))
|
||||
) {
|
||||
state = ParsingState.Novel;
|
||||
}
|
||||
|
||||
if (state !== ParsingState.Novel) return;
|
||||
|
||||
switch (name) {
|
||||
case 'a':
|
||||
if (attribs.class === 'txtA' || attribs.class === 'title') {
|
||||
tempNovel.path = attribs.href.split('/').slice(1, 3).join('/');
|
||||
state = ParsingState.NovelName;
|
||||
}
|
||||
break;
|
||||
case 'img':
|
||||
tempNovel.cover = attribs.src;
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
ontext(text) {
|
||||
if (state === ParsingState.NovelName) {
|
||||
tempNovel.name = (tempNovel.name || '') + text;
|
||||
}
|
||||
},
|
||||
|
||||
onclosetag(name) {
|
||||
if (name === 'a') {
|
||||
if (tempNovel.name && tempNovel.cover) {
|
||||
novels.push(tempNovel as Plugin.NovelItem);
|
||||
tempNovel = {};
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}list/`;
|
||||
|
||||
const filterValues = [
|
||||
filters.genre.value,
|
||||
filters.status.value,
|
||||
filters.lang.value,
|
||||
].filter(value => value !== '');
|
||||
|
||||
if (filterValues.length > 0) {
|
||||
link += filterValues.join('-') + '/';
|
||||
}
|
||||
|
||||
link += (filters.order.value ? '-' : '') + filters.sort.value;
|
||||
link += `/?page=${page}`;
|
||||
|
||||
const body = await fetchApi(link).then(r => r.text());
|
||||
return this.parseNovels(body);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const slash = novelPath.endsWith('/') ? '' : '/';
|
||||
const result = await fetchApi(this.site + novelPath + slash);
|
||||
const body = await result.text();
|
||||
|
||||
const novel: Partial<Plugin.SourceNovel> = {
|
||||
path: novelPath,
|
||||
};
|
||||
|
||||
let depth = 0;
|
||||
let state = ParsingState.Idle;
|
||||
let tempChapter: Partial<Plugin.ChapterItem> = {};
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
const summaryParts: string[] = [];
|
||||
const genreArray: string[] = [];
|
||||
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
switch (name) {
|
||||
case 'div':
|
||||
if (attribs.id === 'Cover') {
|
||||
state = ParsingState.Cover;
|
||||
}
|
||||
if (attribs.class?.includes('item-box')) {
|
||||
state = ParsingState.Chapter;
|
||||
const path = attribs.onclick.match(/\d+/)![0];
|
||||
tempChapter.path = `${novelPath}${slash}${path}.html`;
|
||||
return;
|
||||
}
|
||||
if (state === ParsingState.Chapter) depth++;
|
||||
break;
|
||||
case 'img':
|
||||
if (state === ParsingState.Cover) {
|
||||
novel.name = attribs.title;
|
||||
novel.cover = attribs.src;
|
||||
}
|
||||
break;
|
||||
case 'p':
|
||||
if (attribs.class === 'txtItme') {
|
||||
if (!novel.status) {
|
||||
state = ParsingState.Status;
|
||||
} else if (!novel.author) {
|
||||
state = ParsingState.Author;
|
||||
}
|
||||
} else if (attribs.id === 'full-des') {
|
||||
state = ParsingState.Summary;
|
||||
}
|
||||
break;
|
||||
case 'br':
|
||||
summaryParts.push('\n');
|
||||
break;
|
||||
case 'a':
|
||||
if (attribs.class?.includes('btn-default')) {
|
||||
state = ParsingState.Genres;
|
||||
}
|
||||
break;
|
||||
case 'span':
|
||||
if (state === ParsingState.Chapter) depth++;
|
||||
if (depth === 2) {
|
||||
state = ParsingState.ChapterName;
|
||||
} else if (depth === 1) {
|
||||
state = ParsingState.ChapterTime;
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
ontext(text) {
|
||||
switch (state) {
|
||||
case ParsingState.Status:
|
||||
novel.status = (novel.status || '') + text.trim();
|
||||
break;
|
||||
case ParsingState.Author:
|
||||
novel.author = (novel.author || '') + text.trim();
|
||||
break;
|
||||
case ParsingState.Genres:
|
||||
genreArray.push(text.trim());
|
||||
break;
|
||||
case ParsingState.Summary:
|
||||
summaryParts.push(text);
|
||||
break;
|
||||
case ParsingState.ChapterName:
|
||||
tempChapter.name = (tempChapter.name || '') + text.trim();
|
||||
break;
|
||||
case ParsingState.ChapterTime:
|
||||
if (text?.includes('Advanced')) return;
|
||||
const releaseDate = text.split('.').map(x => Number(x));
|
||||
if (releaseDate.length === 3) {
|
||||
tempChapter.releaseTime = new Date(
|
||||
releaseDate[0],
|
||||
releaseDate[1] - 1,
|
||||
releaseDate[2],
|
||||
).toISOString();
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
onclosetag(name) {
|
||||
switch (name) {
|
||||
case 'div':
|
||||
if (state === ParsingState.Cover) {
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
if (state === ParsingState.Chapter) {
|
||||
depth--;
|
||||
if (depth < 0) {
|
||||
if (
|
||||
tempChapter.path &&
|
||||
tempChapter.name &&
|
||||
tempChapter.releaseTime
|
||||
) {
|
||||
chapter.push(tempChapter as Plugin.ChapterItem);
|
||||
}
|
||||
tempChapter = {};
|
||||
depth = 0;
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'p':
|
||||
if (
|
||||
state === ParsingState.Status ||
|
||||
state === ParsingState.Author ||
|
||||
state === ParsingState.Summary
|
||||
) {
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
break;
|
||||
case 'a':
|
||||
if (state === ParsingState.Genres) {
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
break;
|
||||
case 'span':
|
||||
if (
|
||||
state === ParsingState.ChapterName ||
|
||||
state === ParsingState.ChapterTime
|
||||
) {
|
||||
state = ParsingState.Chapter;
|
||||
depth--;
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
onend() {
|
||||
novel.genres = genreArray.join(', ');
|
||||
novel.summary = summaryParts.join('');
|
||||
novel.chapters = chapter;
|
||||
},
|
||||
});
|
||||
|
||||
parser.write(body);
|
||||
parser.end();
|
||||
|
||||
return novel as Plugin.SourceNovel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const html = await fetchApi(this.site + chapterPath).then(r => r.text());
|
||||
|
||||
let depth = 0;
|
||||
let state: ParsingState = ParsingState.Idle;
|
||||
const chapterHtml: string[] = [];
|
||||
|
||||
type EscapeChar = '&' | '<' | '>' | '"' | "'" | ' ';
|
||||
const escapeRegex = /[&<>"' ]/g;
|
||||
const escapeMap: Record<EscapeChar, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
' ': ' ',
|
||||
};
|
||||
const escapeHtml = (text: string): string =>
|
||||
text.replace(escapeRegex, char => escapeMap[char as EscapeChar]);
|
||||
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
switch (state) {
|
||||
case ParsingState.Idle:
|
||||
if (name === 'div' && attribs.class === 'main') {
|
||||
state = ParsingState.Chapter;
|
||||
depth++;
|
||||
}
|
||||
break;
|
||||
case ParsingState.Chapter:
|
||||
if (name === 'div') depth++;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === ParsingState.Chapter) {
|
||||
const attr = Object.keys(attribs).map(key => {
|
||||
const value = attribs[key].replace(/"/g, '"');
|
||||
return ` ${key}="${value}"`;
|
||||
});
|
||||
chapterHtml.push(`<${name}${attr.join('')}>`);
|
||||
}
|
||||
},
|
||||
|
||||
ontext(data) {
|
||||
if (state === ParsingState.Chapter) {
|
||||
const text = escapeHtml(data);
|
||||
const icontains = ['pawread', 'tinyurl', 'bit.ly'];
|
||||
|
||||
if (icontains.some(pattern => text?.includes(pattern))) {
|
||||
state = ParsingState.Hidden;
|
||||
chapterHtml.pop();
|
||||
} else {
|
||||
chapterHtml.push(text);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onclosetag(name) {
|
||||
switch (state) {
|
||||
case ParsingState.Chapter:
|
||||
if (!parser['isVoidElement'](name)) {
|
||||
chapterHtml.push(`</${name}>`);
|
||||
}
|
||||
if (name === 'div') depth--;
|
||||
if (depth === 0) {
|
||||
state = ParsingState.Stopped;
|
||||
}
|
||||
break;
|
||||
case ParsingState.Hidden:
|
||||
state = ParsingState.Chapter;
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
|
||||
return chapterHtml.join('');
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const params = new URLSearchParams({
|
||||
keywords: searchTerm,
|
||||
page: page.toString(),
|
||||
});
|
||||
|
||||
const result = await fetchApi(`${this.site}search/?${params.toString()}`);
|
||||
const body = await result.text();
|
||||
|
||||
return this.parseNovels(body);
|
||||
}
|
||||
|
||||
filters = {
|
||||
status: {
|
||||
value: '',
|
||||
label: 'Status',
|
||||
options: [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Completed', value: 'wanjie' },
|
||||
{ label: 'Ongoing', value: 'lianzai' },
|
||||
{ label: 'Hiatus', value: 'hiatus' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
lang: {
|
||||
value: '',
|
||||
label: 'Languages',
|
||||
options: [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Chinese', value: 'chinese' },
|
||||
{ label: 'Korean', value: 'korean' },
|
||||
{ label: 'Japanese', value: 'japanese' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genre: {
|
||||
value: '',
|
||||
label: 'Genres',
|
||||
options: [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Fantasy', value: 'Fantasy' },
|
||||
{ label: 'Action', value: 'Action' },
|
||||
{ label: 'Xuanhuan', value: 'Xuanhuan' },
|
||||
{ label: 'Romance', value: 'Romance' },
|
||||
{ label: 'Comedy', value: 'Comedy' },
|
||||
{ label: 'Mystery', value: 'Mystery' },
|
||||
{ label: 'Mature', value: 'Mature' },
|
||||
{ label: 'Harem', value: 'Harem' },
|
||||
{ label: 'Wuxia', value: 'Wuxia' },
|
||||
{ label: 'Xianxia', value: 'Xianxia' },
|
||||
{ label: 'Tragedy', value: 'Tragedy' },
|
||||
{ label: 'Sci-fi', value: 'Scifi' },
|
||||
{ label: 'Historical', value: 'Historical' },
|
||||
{ label: 'Ecchi', value: 'Ecchi' },
|
||||
{ label: 'Adventure', value: 'Adventure' },
|
||||
{ label: 'Adult', value: 'Adult' },
|
||||
{ label: 'Supernatural', value: 'Supernatural' },
|
||||
{ label: 'Psychological', value: 'Psychological' },
|
||||
{ label: 'Drama', value: 'Drama' },
|
||||
{ label: 'Horror', value: 'Horror' },
|
||||
{ label: 'Josei', value: 'Josei' },
|
||||
{ label: 'Mecha', value: 'Mecha' },
|
||||
{ label: 'Seinen', value: 'Seinen' },
|
||||
{ label: 'Shoujo', value: 'Shoujo' },
|
||||
{ label: 'Shounen', value: 'Shounen' },
|
||||
{ label: 'Smut', value: 'Smut' },
|
||||
{ label: 'Yaoi', value: 'Yaoi' },
|
||||
{ label: 'Yuri', value: 'Yuri' },
|
||||
{ label: 'Martial Arts', value: 'MartialArts' },
|
||||
{ label: 'School Life', value: 'SchoolLife' },
|
||||
{ label: 'Shoujo Ai', value: 'ShoujoAi' },
|
||||
{ label: 'Shounen Ai', value: 'ShounenAi' },
|
||||
{ label: 'Slice of Life', value: 'SliceofLife' },
|
||||
{ label: 'Gender Bender', value: 'GenderBender' },
|
||||
{ label: 'Sports', value: 'Sports' },
|
||||
{ label: 'Urban', value: 'Urban' },
|
||||
{ label: 'Adventurer', value: 'Adventurer' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
sort: {
|
||||
value: 'click',
|
||||
label: 'Sort By',
|
||||
options: [
|
||||
{ label: 'Time Updated', value: 'update' },
|
||||
{ label: 'Time Posted', value: 'post' },
|
||||
{ label: 'Clicks', value: 'click' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
order: {
|
||||
value: false,
|
||||
label: 'Order ↑ ↓',
|
||||
type: FilterTypes.Switch,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new PawRead();
|
||||
|
||||
enum ParsingState {
|
||||
Idle,
|
||||
Cover,
|
||||
Genres,
|
||||
Author,
|
||||
Status,
|
||||
Hidden,
|
||||
Summary,
|
||||
Stopped,
|
||||
Chapter,
|
||||
ChapterName,
|
||||
ChapterTime,
|
||||
NovelName,
|
||||
Novel,
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class Rainofsnow implements Plugin.PagePlugin {
|
||||
id = 'rainofsnow';
|
||||
name = 'Rainofsnow';
|
||||
icon = 'src/en/rainofsnow/icon.png';
|
||||
site = 'https://rainofsnow.com/';
|
||||
version = '1.1.2';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.minbox').each((index, element) => {
|
||||
const name = loadedCheerio(element).find('h3').text();
|
||||
const cover = loadedCheerio(element).find('img').attr('data-src');
|
||||
const path = loadedCheerio(element)
|
||||
.find('h3 > a')
|
||||
.attr('href')
|
||||
?.replace(this.site, '')
|
||||
.replace(/\/+$/, '');
|
||||
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
|
||||
novels.push({ name, cover, path });
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = this.site + 'novels/page/' + pageNo + filters.genre.value;
|
||||
const body = await fetchApi(url).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
async parseNovel(
|
||||
novelPath: string,
|
||||
): Promise<Plugin.SourceNovel & { totalPages: number }> {
|
||||
const novel: Plugin.SourceNovel & { totalPages: number } = {
|
||||
path: novelPath,
|
||||
name: '',
|
||||
totalPages: 0,
|
||||
};
|
||||
|
||||
const body = await fetchApi(this.site + novelPath).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
novel.name = loadedCheerio('.text h2').text().trim();
|
||||
|
||||
novel.cover = loadedCheerio('.imagboca1 img').attr('data-src');
|
||||
|
||||
novel.summary = loadedCheerio('#synop').text().trim();
|
||||
|
||||
novel.genres = loadedCheerio('span:contains("Genre(s)")')
|
||||
.next()
|
||||
.text()
|
||||
.trim();
|
||||
|
||||
novel.author = loadedCheerio('span:contains("Author")').next().text();
|
||||
|
||||
let x = 1;
|
||||
loadedCheerio('.page-numbers li').each((i, el) => {
|
||||
const num = loadedCheerio(el).find('a').text().trim().match(/(\d+)/);
|
||||
const n = Number(num?.[1] || '0');
|
||||
if (n > x) {
|
||||
x = n;
|
||||
}
|
||||
});
|
||||
|
||||
novel.totalPages = x;
|
||||
novel.chapters = this.parseChapters(loadedCheerio);
|
||||
novel.status = NovelStatus.Unknown;
|
||||
return novel;
|
||||
}
|
||||
|
||||
// parse paged chapters
|
||||
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
|
||||
const url = this.site + novelPath + '/page/' + page + '/#chapter';
|
||||
const body = await fetchApi(url).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const chapters = this.parseChapters(loadedCheerio);
|
||||
return { chapters };
|
||||
}
|
||||
|
||||
// helper to parse a novel
|
||||
parseChapters(loadedCheerio: CheerioAPI) {
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('#chapter .march1 li').each((i, el) => {
|
||||
const path = loadedCheerio(el)
|
||||
.find('a')
|
||||
.attr('href')
|
||||
?.slice(this.site.length);
|
||||
if (!path) return;
|
||||
const name = loadedCheerio(el).find('.chapter').first().text().trim();
|
||||
const date = loadedCheerio(el).find('small').text().trim().toLowerCase();
|
||||
const months = [
|
||||
'jan',
|
||||
'feb',
|
||||
'mar',
|
||||
'apr',
|
||||
'may',
|
||||
'jun',
|
||||
'jul',
|
||||
'aug',
|
||||
'sep',
|
||||
'oct',
|
||||
'nov',
|
||||
'dec',
|
||||
];
|
||||
const regex = /([a-z]+) (..?), (.+)/;
|
||||
const [, monthName, day, year] = regex.exec(date) || [];
|
||||
const month = months.indexOf(monthName.slice(0, 3));
|
||||
const releaseTime =
|
||||
monthName && month !== -1
|
||||
? new Date(+year, month, +day).toISOString()
|
||||
: null;
|
||||
|
||||
chapter.push({
|
||||
name,
|
||||
path,
|
||||
releaseTime,
|
||||
});
|
||||
});
|
||||
|
||||
return chapter;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
// parse chapter text here
|
||||
const result = await fetch(this.site + chapterPath);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const chapterName = loadedCheerio('.content > h2').text();
|
||||
const chapterText = loadedCheerio('.content').html();
|
||||
if (!chapterText) return '';
|
||||
return chapterName + '\n' + chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
// get novels using the search term
|
||||
// no page number, infinite scroll
|
||||
|
||||
const newSearch = searchTerm.replace(/\s+/g, '+');
|
||||
const url = this.site + '?s=' + encodeURIComponent(newSearch);
|
||||
|
||||
const result = await fetch(url);
|
||||
const body = await result.text();
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
genre: {
|
||||
value: '',
|
||||
label: 'Filter By',
|
||||
options: [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Action', value: '?n_orderby=16' },
|
||||
{ label: 'Adventure', value: '?n_orderby=11' },
|
||||
{ label: 'Angst', value: '?n_orderby=776' },
|
||||
{ label: 'Chinese', value: '?n_orderby=342' },
|
||||
{ label: 'Comedy', value: '?n_orderby=13' },
|
||||
{ label: 'Drama', value: '?n_orderby=3' },
|
||||
{ label: 'Fantasy', value: '?n_orderby=7' },
|
||||
{ label: 'Japanese', value: '?n_orderby=343' },
|
||||
{ label: 'Korean', value: '?n_orderby=341' },
|
||||
{ label: 'Mature', value: '?n_orderby=778' },
|
||||
{ label: 'Mystery', value: '?n_orderby=12' },
|
||||
{ label: 'Original Novel', value: '?n_orderby=339' },
|
||||
{ label: 'Psychological', value: '?n_orderby=769' },
|
||||
{ label: 'Romance', value: '?n_orderby=5' },
|
||||
{ label: 'Sci-fi', value: '?n_orderby=14' },
|
||||
{ label: 'Slice of Life', value: '?n_orderby=779' },
|
||||
{ label: 'Supernatural', value: '?n_orderby=780' },
|
||||
{ label: 'Tragedy', value: '?n_orderby=777' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new Rainofsnow();
|
||||
@@ -0,0 +1,212 @@
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
import { CheerioAPI, load as loadCheerio } from 'cheerio';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
|
||||
class ReadFromPlugin implements Plugin.PluginBase {
|
||||
id = 'readfrom';
|
||||
name = 'Read From Net';
|
||||
icon = 'src/en/readfrom/icon.png';
|
||||
site = 'https://readfrom.net';
|
||||
version = '1.0.2';
|
||||
filters: Filters | undefined = undefined;
|
||||
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
|
||||
|
||||
//flag indicates whether access to LocalStorage, SesesionStorage is required.
|
||||
webStorageUtilized?: boolean;
|
||||
|
||||
loadedNovelCache: (Plugin.NovelItem & {
|
||||
summary: string;
|
||||
genres: string;
|
||||
author: string;
|
||||
})[] = [];
|
||||
|
||||
parseNovels(
|
||||
loadedCheerio: CheerioAPI,
|
||||
isSearch?: boolean,
|
||||
): (Plugin.NovelItem & {
|
||||
summary: string;
|
||||
genres: string;
|
||||
author: string;
|
||||
})[] {
|
||||
const ret = loadedCheerio(
|
||||
(isSearch ? 'div.text' : '#dle-content') + ' > article.box',
|
||||
)
|
||||
.map((i, el) => {
|
||||
const summary = loadedCheerio(el).find(
|
||||
isSearch ? 'div.text5' : 'div.text3',
|
||||
)[0];
|
||||
loadedCheerio(summary).find('.coll-ellipsis').remove();
|
||||
loadedCheerio(summary).find('a').remove();
|
||||
return {
|
||||
name: loadedCheerio(el).find('h2.title').text().trim(),
|
||||
path: loadedCheerio(el)
|
||||
.find('h2.title > a')
|
||||
.attr('href')!
|
||||
.replace('https://readfrom.net/', '')
|
||||
.replace(/^\//, ''),
|
||||
cover: loadedCheerio(el).find('img').attr('src') || defaultCover,
|
||||
summary:
|
||||
loadedCheerio(summary).text().trim() +
|
||||
loadedCheerio(summary).find('span.coll-hidden').text(),
|
||||
genres: loadedCheerio(el)
|
||||
.find(isSearch ? 'h5.title > a' : 'h2 > a')
|
||||
.filter((i, el) => el.attribs['title']?.startsWith?.('Genre - '))
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(', '),
|
||||
author: isSearch
|
||||
? loadedCheerio(el)
|
||||
.find('h5.title > a')
|
||||
.filter((i, el) =>
|
||||
el.attribs['title']?.startsWith?.('Book author - '),
|
||||
)
|
||||
.text()
|
||||
: loadedCheerio(el).find('h4 > a').text(),
|
||||
};
|
||||
})
|
||||
.toArray();
|
||||
|
||||
this.loadedNovelCache.push(...ret);
|
||||
while (this.loadedNovelCache.length > 100) {
|
||||
this.loadedNovelCache.shift();
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
) {
|
||||
const type = showLatestNovels ? 'last_added_books' : 'allbooks';
|
||||
const res = await fetchApi(
|
||||
'https://readfrom.net/' + type + '/page/' + pageNo + '/',
|
||||
);
|
||||
const text = await res.text();
|
||||
return this.parseNovels(loadCheerio(text));
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const data = await fetchApi('https://readfrom.net/' + novelPath);
|
||||
const text = await data.text();
|
||||
const loadedCheerio = loadCheerio(text);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: 'Untitled',
|
||||
};
|
||||
|
||||
novel.name = loadedCheerio('center > h2.title')
|
||||
.text()
|
||||
.split(', \n\n')[0]
|
||||
.trim();
|
||||
novel.cover =
|
||||
loadedCheerio('article.box > div > center > div > a > img').attr('src') ||
|
||||
defaultCover;
|
||||
|
||||
novel.chapters = loadedCheerio(loadedCheerio('div.pages').get()[0])
|
||||
.find('> a')
|
||||
.map((i, el) => {
|
||||
return {
|
||||
name: loadedCheerio(el).text().trim(),
|
||||
path: loadedCheerio(el)
|
||||
.attr('href')!
|
||||
.replace('https://readfrom.net/', '')
|
||||
.replace(/^\//, ''),
|
||||
// releaseTime: '',
|
||||
chapterNumber: i + 2,
|
||||
};
|
||||
})
|
||||
.toArray();
|
||||
novel.chapters.unshift({
|
||||
name: '1',
|
||||
path: novelPath,
|
||||
chapterNumber: 1,
|
||||
});
|
||||
|
||||
let moreNovelInfo = this.loadedNovelCache.find(
|
||||
novel => novel.path === novelPath,
|
||||
);
|
||||
if (!moreNovelInfo)
|
||||
moreNovelInfo = (await this.searchNovels(novel.name, 1)).find(
|
||||
novel => novel.path === novelPath,
|
||||
);
|
||||
if (moreNovelInfo) {
|
||||
novel.summary = moreNovelInfo.summary;
|
||||
novel.genres = moreNovelInfo.genres;
|
||||
novel.author = moreNovelInfo.author;
|
||||
}
|
||||
|
||||
const seriesElm = loadedCheerio('center > b:has(a)').filter((i, el) =>
|
||||
loadedCheerio(el).find('a').attr('href')!.startsWith('/series.html'),
|
||||
)[0];
|
||||
|
||||
if (seriesElm) {
|
||||
const seriesText = loadedCheerio(seriesElm).text().trim();
|
||||
|
||||
novel.summary = seriesText + '\n\n' + novel.summary;
|
||||
}
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const data = await fetchApi('https://readfrom.net/' + chapterPath);
|
||||
const text = await data.text();
|
||||
const loadedCheerio = loadCheerio(text);
|
||||
loadedCheerio('#textToRead > span:empty').remove();
|
||||
loadedCheerio('#textToRead > center').remove();
|
||||
|
||||
const textToRead = loadedCheerio('#textToRead');
|
||||
|
||||
let paragraph: string[] = [];
|
||||
const chapterHtml: string[] = [];
|
||||
|
||||
textToRead.contents().each((_, element) => {
|
||||
switch (element.type) {
|
||||
case 'text':
|
||||
const content = element.data.trim();
|
||||
if (content) {
|
||||
paragraph.push(content);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tag':
|
||||
if (paragraph.length > 0) {
|
||||
chapterHtml.push(`<p>${paragraph.join(' ')}</p>`);
|
||||
paragraph = [];
|
||||
}
|
||||
if (element.tagName !== 'br') {
|
||||
chapterHtml.push(loadedCheerio.html(element));
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Close any remaining paragraph
|
||||
if (paragraph.length > 0) {
|
||||
chapterHtml.push(`<p>${paragraph.join(' ')}</p>`);
|
||||
}
|
||||
|
||||
return chapterHtml.join('');
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string, pageNo: number) {
|
||||
if (pageNo !== 1) return [];
|
||||
const res = await fetchApi(
|
||||
'https://readfrom.net/build_in_search/?q=' +
|
||||
encodeURIComponent(searchTerm),
|
||||
);
|
||||
const text = await res.text();
|
||||
return this.parseNovels(loadCheerio(text), true);
|
||||
}
|
||||
|
||||
resolveUrl = (path: string, isNovel?: boolean) => this.site + '/' + path;
|
||||
}
|
||||
|
||||
export default new ReadFromPlugin();
|
||||
@@ -0,0 +1,190 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { isUrlAbsolute } from '@libs/isAbsoluteUrl';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class ReadLiteNovel implements Plugin.PluginBase {
|
||||
id = 'rln.app';
|
||||
name = 'ReadLiteNovel';
|
||||
version = '1.0.1';
|
||||
icon = 'src/en/readlitenovel/icon.png';
|
||||
site = 'https://rln.app';
|
||||
|
||||
parseAgoDate(date: string | undefined) {
|
||||
//parseMadaraDate
|
||||
if (date?.includes('ago')) {
|
||||
const dayJSDate = dayjs(new Date()); // today
|
||||
const timeAgo = date.match(/\d+/)?.[0] || '';
|
||||
const timeAgoInt = parseInt(timeAgo, 10);
|
||||
|
||||
if (!timeAgo) return null; // there is no number!
|
||||
|
||||
if (date.includes('hours ago') || date.includes('hour ago')) {
|
||||
dayJSDate.subtract(timeAgoInt, 'hours'); // go back N hours
|
||||
}
|
||||
|
||||
if (date.includes('days ago') || date.includes('day ago')) {
|
||||
dayJSDate.subtract(timeAgoInt, 'days'); // go back N days
|
||||
}
|
||||
|
||||
if (date.includes('months ago') || date.includes('month ago')) {
|
||||
dayJSDate.subtract(timeAgoInt, 'months'); // go back N months
|
||||
}
|
||||
|
||||
if (date.includes('years ago') || date.includes('year ago')) {
|
||||
dayJSDate.subtract(timeAgoInt, 'years'); // go back N years
|
||||
}
|
||||
|
||||
return dayJSDate.toISOString();
|
||||
}
|
||||
return null; // there is no "ago" so give up
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const link = `${this.site}/ranking/${filters.order.value}/${page}`;
|
||||
const result = await fetchApi(link);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.category-items li').each((i, el) => {
|
||||
const novelUrl = loadedCheerio(el).find('.category-name a').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
const novelName = loadedCheerio(el)
|
||||
.find('.category-name a')
|
||||
.text()
|
||||
.trim();
|
||||
let novelCover = loadedCheerio(el).find('.category-img img').attr('src');
|
||||
|
||||
if (novelCover && !isUrlAbsolute(novelCover)) {
|
||||
novelCover = this.site + novelCover;
|
||||
}
|
||||
|
||||
const novel = {
|
||||
path: novelUrl?.replace(this.site, ''),
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
};
|
||||
novels.push(novel);
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const result = await fetchApi(this.site + novelPath);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.novel-title').text() || 'Untitled',
|
||||
cover: loadedCheerio('.novels-detail img').attr('src'),
|
||||
summary: loadedCheerio('.empty-box').text().trim(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
loadedCheerio('.novels-detail-right li').each((i, el) => {
|
||||
const detailName = loadedCheerio(el).find('div:first').text();
|
||||
const detail = loadedCheerio(el).find('div:last');
|
||||
|
||||
switch (detailName) {
|
||||
case 'Status:':
|
||||
novel.status = detail.text();
|
||||
break;
|
||||
case 'Genres:':
|
||||
novel.genres =
|
||||
detail
|
||||
.find('a')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(', ') || detail.text();
|
||||
break;
|
||||
case 'Author(s):':
|
||||
novel.author =
|
||||
detail
|
||||
.find('a')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(', ') || detail.text();
|
||||
break;
|
||||
case 'Translator:':
|
||||
novel.artist = detail.text();
|
||||
break;
|
||||
}
|
||||
});
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
loadedCheerio('.cm-tabs-content li').each((i, el) => {
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href');
|
||||
if (!chapterUrl) return;
|
||||
const chapterName = loadedCheerio(el).find('a').text().trim();
|
||||
const releaseDate = this.parseAgoDate(
|
||||
loadedCheerio(el).find('svg').attr('data-bs-original-title')!,
|
||||
);
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl?.replace(this.site, ''),
|
||||
releaseTime: releaseDate,
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapter;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(this.site + chapterPath);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const chapterText = loadedCheerio('#chapterText').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const url =
|
||||
this.site +
|
||||
`/search/autocomplete?dataType=json&query=${encodeURIComponent(searchTerm)}`;
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.json();
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
body.results.forEach(
|
||||
(item: { link: string; original_title: string; image: string }) =>
|
||||
novels.push({
|
||||
path: item.link,
|
||||
name: item.original_title,
|
||||
cover: item.image,
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
order: {
|
||||
value: 'top-rated',
|
||||
label: 'Order by',
|
||||
options: [
|
||||
{ label: 'MOST VIEWED', value: 'most-viewed' },
|
||||
{ label: 'TOP RATED', value: 'top-rated' },
|
||||
{ label: 'BOOKMARKS', value: 'subscribers' },
|
||||
{ label: 'NEW', value: 'new' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new ReadLiteNovel();
|
||||
@@ -0,0 +1,128 @@
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import type { Plugin } from '@/types/plugin';
|
||||
|
||||
const API_BASE = 'https://api.reaperscans.com';
|
||||
const MEDIA_BASE = 'https://media.reaperscans.com/file/4SRBHm/';
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
meta: {
|
||||
total: number;
|
||||
per_page: number;
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
};
|
||||
};
|
||||
|
||||
type ReaperNovel = {
|
||||
title: string;
|
||||
thumbnail: string;
|
||||
series_slug: string;
|
||||
};
|
||||
|
||||
class ReaperScans implements Plugin.PluginBase {
|
||||
id = 'reaperscans.com';
|
||||
name = 'Reaper Scans';
|
||||
version = '1.0.0';
|
||||
icon = 'src/en/reaperscans/icon.png';
|
||||
site = 'https://reaperscans.com';
|
||||
|
||||
async popularNovels(page: number): Promise<Plugin.NovelItem[]> {
|
||||
return this.query(page);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novelResp = await fetchApi(`${API_BASE}/series/${novelPath}`);
|
||||
const novel: {
|
||||
title: string;
|
||||
thumbnail: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
rating: number;
|
||||
status: string;
|
||||
alternative_names: string;
|
||||
author: string;
|
||||
studio: string;
|
||||
} = await novelResp.json();
|
||||
|
||||
const chaptersResp = await fetchApi(
|
||||
`${API_BASE}/chapters/${novelPath}?perPage=${Number.MAX_SAFE_INTEGER}`,
|
||||
);
|
||||
const chapters: {
|
||||
chapter_slug: string;
|
||||
chapter_name: string;
|
||||
index: string;
|
||||
created_at: string;
|
||||
}[] = (await chaptersResp.json()).data;
|
||||
|
||||
return {
|
||||
name: novel.title,
|
||||
cover: this.getCoverUrl(novel.thumbnail),
|
||||
author: novel.author,
|
||||
artist: novel.studio,
|
||||
status: novel.status,
|
||||
rating: novel.rating,
|
||||
summary: novel.description,
|
||||
genres: novel.tags.join(','),
|
||||
path: novelPath,
|
||||
chapters: chapters.reverse().map(chapter => ({
|
||||
path: `${novelPath}/${chapter.chapter_slug}`,
|
||||
name: chapter.chapter_name,
|
||||
chapterNumber: Number.parseFloat(chapter.index),
|
||||
releaseTime: chapter.created_at.substring(0, 10),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(`${this.site}/series/${chapterPath}`, {
|
||||
headers: { RSC: '1' },
|
||||
});
|
||||
const body = await result.text();
|
||||
return this.extractChapterContent(body);
|
||||
}
|
||||
|
||||
private extractChapterContent(chapter: string): string {
|
||||
const lines = chapter.split('\n');
|
||||
const start = lines.findIndex(line => line.includes('<p'));
|
||||
|
||||
const prefix = lines[start].substring(0, lines[start].indexOf('<'));
|
||||
const commonPrefix = prefix.substring(
|
||||
prefix.indexOf(':'),
|
||||
prefix.indexOf(','),
|
||||
);
|
||||
|
||||
const end = lines.lastIndexOf(commonPrefix);
|
||||
const content = lines.slice(start, end).join('\n');
|
||||
|
||||
const deduplicated = content.split(commonPrefix)[1];
|
||||
return deduplicated.substring(
|
||||
deduplicated.indexOf('<'),
|
||||
deduplicated.lastIndexOf('>') + 1,
|
||||
);
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
return this.query(1, searchTerm);
|
||||
}
|
||||
|
||||
private async query(page = 1, search = ''): Promise<Plugin.NovelItem[]> {
|
||||
const link = `${API_BASE}/query?page=${page}&perPage=20&series_type=Novel&query_string=${search}&order=desc&orderBy=created_at&adult=true&status=All&tags_ids=[]`;
|
||||
const result = await fetchApi(link);
|
||||
const json: ApiResponse<ReaperNovel[]> = await result.json();
|
||||
|
||||
return json.data.map(novel => ({
|
||||
name: novel.title,
|
||||
cover: novel.thumbnail.startsWith('novels/')
|
||||
? MEDIA_BASE + novel.thumbnail
|
||||
: novel.thumbnail,
|
||||
path: novel.series_slug,
|
||||
}));
|
||||
}
|
||||
|
||||
private getCoverUrl(thumbnail: string): string {
|
||||
return thumbnail.startsWith('novels/') ? MEDIA_BASE + thumbnail : thumbnail;
|
||||
}
|
||||
}
|
||||
|
||||
export default new ReaperScans();
|
||||
@@ -0,0 +1,427 @@
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
import { load as loadCheerio } from 'cheerio';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelItem } from '../../test_web/static/js';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
type FuzzySearchOptions = {
|
||||
caseSensitive: boolean;
|
||||
sort: boolean;
|
||||
};
|
||||
|
||||
// Shamelessly stolen from 'https://raw.githubusercontent.com/wouterrutgers/fuzzy-search/master/src/FuzzySearch.mjs'
|
||||
// I did modify the code a fair bit, but the algorithm is still the same under the hood.
|
||||
// The original code is under ISC, which stipluate that it needs to retain the copyright notice, so here it is.
|
||||
//
|
||||
// It only applies to the code in the FuzzySearch Class, even though I did modify it a fair bit
|
||||
|
||||
/*
|
||||
Copyright (c) 2016, Wouter Rutgers
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
*/
|
||||
|
||||
class FuzzySearch<Item> {
|
||||
private haystack: Item[];
|
||||
private options: FuzzySearchOptions;
|
||||
private getItems: (item: Item) => string[];
|
||||
|
||||
constructor(
|
||||
getItems: (item: Item) => string[],
|
||||
options: Partial<FuzzySearchOptions>,
|
||||
) {
|
||||
this.haystack = [];
|
||||
this.options = Object.assign(
|
||||
{
|
||||
caseSensitive: false,
|
||||
sort: false,
|
||||
},
|
||||
options,
|
||||
);
|
||||
this.getItems = getItems;
|
||||
}
|
||||
|
||||
getOptions(): FuzzySearchOptions {
|
||||
return this.options;
|
||||
}
|
||||
|
||||
setOptions(options: Partial<FuzzySearchOptions>) {
|
||||
this.options = Object.assign(this.options, options);
|
||||
}
|
||||
|
||||
setHaystack(items: Item[]) {
|
||||
this.haystack = items;
|
||||
}
|
||||
|
||||
getHaystack(): Item[] {
|
||||
return this.haystack;
|
||||
}
|
||||
|
||||
search(query: string): Item[] {
|
||||
if (query.length === 0) {
|
||||
return this.haystack;
|
||||
}
|
||||
const results: { item: Item; score: number }[] = [];
|
||||
|
||||
for (const item of this.haystack) {
|
||||
for (const value of this.getItems(item)) {
|
||||
const score = this.isMatch(value, query);
|
||||
if (score !== undefined) {
|
||||
results.push({ item, score });
|
||||
/// We don't want to have multiple occurence of the same item, so only take one if it is found
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.options.sort) {
|
||||
results.sort((a, b) => a.score - b.score);
|
||||
}
|
||||
|
||||
return results.map(result => result.item);
|
||||
}
|
||||
|
||||
isMatch(item: string, query: string): number | undefined {
|
||||
if (!this.options.caseSensitive) {
|
||||
item = item.toLocaleLowerCase();
|
||||
query = query.toLocaleLowerCase();
|
||||
}
|
||||
|
||||
const indexes = this.nearestIndexesFor(item, query);
|
||||
|
||||
if (indexes === undefined) {
|
||||
return;
|
||||
}
|
||||
// Exact matches should be first.
|
||||
if (item === query) {
|
||||
return 1;
|
||||
}
|
||||
// If we have more than 2 letters, matches close to each other should be first.
|
||||
if (indexes.length > 1) {
|
||||
return 2 + (indexes[indexes.length - 1] - indexes[0]);
|
||||
}
|
||||
// Matches closest to the start of the string should be first.
|
||||
return 2 + indexes[0];
|
||||
}
|
||||
|
||||
nearestIndexesFor(item: string, query: string): number[] | undefined {
|
||||
const letters = query.split('');
|
||||
const indexes: number[][] = [];
|
||||
|
||||
const idxFL = this.idxFirstLetter(item, query);
|
||||
|
||||
for (const idx of idxFL) {
|
||||
indexes.push([idx]);
|
||||
for (let i = 1; i < letters.length; i++) {
|
||||
const letter = letters[i];
|
||||
let index = item.indexOf(letter, idx + 1);
|
||||
if (index === -1) {
|
||||
indexes.pop();
|
||||
break;
|
||||
}
|
||||
indexes[indexes.length - 1].push(index);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
if (indexes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return indexes.sort((a, b) => {
|
||||
if (a.length === 1) {
|
||||
return a[0] - b[0];
|
||||
}
|
||||
|
||||
const res_a = a[a.length - 1] - a[0];
|
||||
const res_b = b[b.length - 1] - b[0];
|
||||
|
||||
return res_a - res_b;
|
||||
})[0];
|
||||
}
|
||||
|
||||
idxFirstLetter(item: string, query: string): number[] {
|
||||
const match = query[0];
|
||||
|
||||
return item
|
||||
.split('')
|
||||
.map((letter, index) => {
|
||||
if (letter !== match) {
|
||||
return;
|
||||
}
|
||||
return index;
|
||||
})
|
||||
.filter(index => index !== undefined) as number[];
|
||||
}
|
||||
}
|
||||
|
||||
class ReLibraryPlugin implements Plugin.PluginBase {
|
||||
id = 'ReLib';
|
||||
name = 'Re:Library';
|
||||
icon = 'src/en/relibrary/icon.png';
|
||||
site = 'https://re-library.com';
|
||||
version = '1.0.2';
|
||||
imageRequestInit: Plugin.ImageRequestInit = {
|
||||
headers: {
|
||||
Referer: 'https://re-library.com/',
|
||||
},
|
||||
};
|
||||
|
||||
private searchFunc = new FuzzySearch<Plugin.NovelItem>(item => [item.name], {
|
||||
sort: true,
|
||||
caseSensitive: false,
|
||||
});
|
||||
|
||||
private async popularNovelsInner(url: string): Promise<Plugin.NovelItem[]> {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
loadedCheerio('.entry-content > ol > li').each((_i, el) => {
|
||||
const novel: Partial<NovelItem> = {};
|
||||
novel.name = loadedCheerio(el).find('h3 > a').text();
|
||||
novel.path = loadedCheerio(el)
|
||||
.find('table > tbody > tr > td > a')
|
||||
.attr('href');
|
||||
if (novel.name === undefined || novel.path === undefined) return;
|
||||
novel.cover =
|
||||
loadedCheerio(el)
|
||||
.find('table > tbody > tr > td > a > img')
|
||||
.attr('data-cfsrc') ||
|
||||
loadedCheerio(el)
|
||||
.find('table > tbody > tr > td > a > img')
|
||||
.attr('src') ||
|
||||
defaultCover;
|
||||
if (novel.path.startsWith(this.site)) {
|
||||
novel.path = novel.path.slice(this.site.length);
|
||||
}
|
||||
novels.push(novel as Plugin.NovelItem);
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
private async lastestNovelsInner(url: string): Promise<Plugin.NovelItem[]> {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
loadedCheerio('article.type-page.page').each((_i, el) => {
|
||||
const novel: Partial<Plugin.NovelItem> = {};
|
||||
novel.name = loadedCheerio(el).find('.entry-title').text();
|
||||
novel.path = loadedCheerio(el).find('.entry-title a').attr('href');
|
||||
if (novel.path === undefined || novel.name === undefined) return;
|
||||
novel.cover =
|
||||
loadedCheerio(el)
|
||||
.find('.entry-content > table > tbody > tr > td > a >img')
|
||||
.attr('data-cfsrc') ||
|
||||
loadedCheerio(el)
|
||||
.find('.entry-content > table > tbody > tr > td > a >img')
|
||||
.attr('src') ||
|
||||
defaultCover;
|
||||
if (novel.path.startsWith(this.site)) {
|
||||
novel.path = novel.path.slice(this.site.length);
|
||||
}
|
||||
novels.push(novel as Plugin.NovelItem);
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ showLatestNovels }: Plugin.PopularNovelsOptions<Filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
// The most-popular page only has a single page, so we return an empty array in case you ask for the impossible
|
||||
// the lastest page do have paginated result, so we support that.
|
||||
if (showLatestNovels)
|
||||
return this.lastestNovelsInner(
|
||||
`${this.site}/tag/translations/page/${pageNo}`,
|
||||
);
|
||||
else if (pageNo === 1)
|
||||
return this.popularNovelsInner(`${this.site}/translations/most-popular/`);
|
||||
else return [];
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novel: Partial<Plugin.SourceNovel> = {
|
||||
path: novelPath,
|
||||
};
|
||||
// title: .entry-content > header.entry-header > .entry-title
|
||||
// img: .entry-content > table > tbody > tr > td > img
|
||||
// tags: .entry-content > table > tbody > tr > td > p > span > a[]
|
||||
// synopis: .entry-content > div.su-box > div.su-box-content
|
||||
// chapters: .entry-content > div.su-accordion <then> li.page_item[]
|
||||
|
||||
const result = await fetchApi(`${this.site}/${novelPath}`);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
|
||||
// If it doesn't find the name I should just throw an error (or early return) since the scraping is broken
|
||||
novel.name = loadedCheerio('header.entry-header > .entry-title')
|
||||
.text()
|
||||
.trim();
|
||||
|
||||
if (novel.name === undefined || novel.name === '404 – Page not found')
|
||||
throw new Error(`Invalid novel for url ${novelPath}`);
|
||||
|
||||
// Find the cover
|
||||
novel.cover =
|
||||
loadedCheerio('.entry-content > table img').attr('data-cfsrc') ||
|
||||
loadedCheerio('.entry-content > table img').attr('src') ||
|
||||
defaultCover;
|
||||
|
||||
novel.status = NovelStatus.Unknown;
|
||||
loadedCheerio('.entry-content > table > tbody > tr > td > p').each(
|
||||
function (_i, el) {
|
||||
// Handle the novel status
|
||||
// Sadly some novels just state the status inside the summary...
|
||||
if (
|
||||
loadedCheerio(el)
|
||||
.find('strong')
|
||||
.text()
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.startsWith('status')
|
||||
) {
|
||||
loadedCheerio(el).find('strong').remove();
|
||||
const status = loadedCheerio(el).text().toLowerCase().trim();
|
||||
if (status.includes('on-going')) {
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
} else if (status.includes('completed')) {
|
||||
novel.status = NovelStatus.Completed;
|
||||
} else if (status.includes('hiatus')) {
|
||||
novel.status = NovelStatus.OnHiatus;
|
||||
} else if (status.includes('cancelled')) {
|
||||
novel.status = NovelStatus.Cancelled;
|
||||
} else {
|
||||
novel.status = loadCheerio(el).text();
|
||||
}
|
||||
}
|
||||
// Handle the genres
|
||||
else if (
|
||||
loadedCheerio(el)
|
||||
.find('strong')
|
||||
.text()
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.startsWith('Category')
|
||||
) {
|
||||
loadedCheerio(el).find('strong').remove();
|
||||
novel.genres = loadedCheerio(el).text();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
novel.summary = loadedCheerio(
|
||||
'.entry-content > div.su-box > div.su-box-content',
|
||||
).text();
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
let chapter_idx = 0;
|
||||
loadedCheerio('.entry-content > div.su-accordion').each((_i1, el) => {
|
||||
loadedCheerio(el)
|
||||
.find('li > a')
|
||||
.each((_i2, chap_el) => {
|
||||
chapter_idx += 1;
|
||||
let chap_path = loadedCheerio(chap_el).attr('href')?.trim();
|
||||
if (
|
||||
loadedCheerio(chap_el).text() === undefined ||
|
||||
chap_path === undefined
|
||||
)
|
||||
return;
|
||||
if (chap_path.startsWith(this.site)) {
|
||||
chap_path = chap_path.slice(this.site.length);
|
||||
}
|
||||
chapters.push({
|
||||
name: loadedCheerio(chap_el).text(),
|
||||
path: chap_path,
|
||||
chapterNumber: chapter_idx,
|
||||
// we KNOW that we can't get the released time (at least without any additional fetches), so set it to null purposfully
|
||||
releaseTime: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel as Plugin.NovelItem;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
// parse chapter text here
|
||||
const result = await fetchApi(`${this.site}/${chapterPath}`);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
|
||||
const entryContent = loadedCheerio('.entry-content');
|
||||
const pageLinkHr = entryContent.find('.PageLink + hr').first();
|
||||
if (pageLinkHr.length) {
|
||||
// Remove all previous siblings before the .PageLink + hr
|
||||
let prev = pageLinkHr.prev();
|
||||
while (prev.length) {
|
||||
prev.remove();
|
||||
prev = pageLinkHr.prev();
|
||||
}
|
||||
const pageLink = pageLinkHr.prev('.PageLink');
|
||||
if (pageLink.length) {
|
||||
pageLink.remove();
|
||||
}
|
||||
pageLinkHr.next().remove();
|
||||
pageLinkHr.remove();
|
||||
}
|
||||
|
||||
// Find the first <hr> followed by a .PageLink and remove everything after
|
||||
const hrAfterPageLink = entryContent.find('hr + .PageLink').first();
|
||||
if (hrAfterPageLink.length) {
|
||||
let next = hrAfterPageLink.next();
|
||||
while (next.length) {
|
||||
const temp = next.next();
|
||||
next.remove();
|
||||
next = temp;
|
||||
}
|
||||
hrAfterPageLink.prev().remove();
|
||||
hrAfterPageLink.remove();
|
||||
}
|
||||
|
||||
return entryContent.html() || '';
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
// We only want to serve a single "page" since we do the search client side.
|
||||
if (pageNo !== 1) return [];
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
const req = await fetchApi(`${this.site}/translations/`);
|
||||
const body = await req.text();
|
||||
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
|
||||
loadedCheerio('article article').each((_i, el) => {
|
||||
const e = loadedCheerio(el);
|
||||
if (e.find('a').attr('href') && e.find('a').text()) {
|
||||
novels.push({
|
||||
name: e.find('h4').text(),
|
||||
path: e.find('a').attr('href')?.replace(this.site, '') || '',
|
||||
cover:
|
||||
e.find('img').attr('data-cfsrc') ||
|
||||
e.find('img').attr('src') ||
|
||||
defaultCover,
|
||||
});
|
||||
}
|
||||
});
|
||||
this.searchFunc.setHaystack(novels);
|
||||
return this.searchFunc.search(searchTerm);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ReLibraryPlugin();
|
||||
@@ -0,0 +1,952 @@
|
||||
import { Parser } from 'htmlparser2';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import { isUrlAbsolute } from '@libs/isAbsoluteUrl';
|
||||
|
||||
class RoyalRoad implements Plugin.PluginBase {
|
||||
id = 'royalroad';
|
||||
name = 'Royal Road';
|
||||
version = '2.2.3';
|
||||
icon = 'src/en/royalroad/icon.png';
|
||||
site = 'https://www.royalroad.com/';
|
||||
|
||||
parseNovels(html: string) {
|
||||
const baseUrl = this.site;
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
let tempNovel: Partial<Plugin.NovelItem> = {};
|
||||
let state: ParsingState = ParsingState.Idle;
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (attribs['class']?.includes('fiction-list-item')) {
|
||||
state = ParsingState.Novel;
|
||||
}
|
||||
if (state !== ParsingState.Novel) return;
|
||||
|
||||
switch (name) {
|
||||
case 'a':
|
||||
if (attribs['href']) {
|
||||
tempNovel.path = attribs['href'].split('/').slice(1, 3).join('/');
|
||||
}
|
||||
break;
|
||||
case 'img':
|
||||
if (attribs['src']) {
|
||||
tempNovel.name = attribs['alt'] || '';
|
||||
tempNovel.cover = !isUrlAbsolute(attribs['src'])
|
||||
? baseUrl + attribs['src'].slice(1)
|
||||
: attribs['src'];
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'figure') {
|
||||
if (tempNovel.path && tempNovel.name) {
|
||||
novels.push(tempNovel as Plugin.NovelItem);
|
||||
tempNovel = {};
|
||||
}
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{
|
||||
filters,
|
||||
showLatestNovels,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
});
|
||||
if (showLatestNovels) {
|
||||
params.append('orderBy', 'last_update');
|
||||
}
|
||||
if (!filters) filters = this.filters || {};
|
||||
for (const key in filters) {
|
||||
if (filters[key as keyof typeof filters].value === '') continue;
|
||||
if (key === 'genres' || key === 'tags' || key === 'content_warnings') {
|
||||
if (filters[key].value.include) {
|
||||
for (const include of filters[key].value.include) {
|
||||
params.append('tagsAdd', include);
|
||||
}
|
||||
}
|
||||
if (filters[key].value.exclude) {
|
||||
for (const exclude of filters[key].value.exclude) {
|
||||
params.append('tagsRemove', exclude);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
params.append(key, String(filters[key as keyof typeof filters].value));
|
||||
}
|
||||
}
|
||||
|
||||
const link = `${this.site}fictions/search?${params.toString()}`;
|
||||
const body = await fetchApi(link).then(r => r.text());
|
||||
|
||||
return this.parseNovels(body);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const result = await fetchApi(this.site + novelPath);
|
||||
const html = await result.text();
|
||||
const novel: Partial<Plugin.SourceNovel> = {
|
||||
path: novelPath,
|
||||
};
|
||||
const baseUrl = this.site;
|
||||
|
||||
let state: ParsingState = ParsingState.Idle;
|
||||
let statusText = '';
|
||||
let statusSpanCounter = 0;
|
||||
|
||||
const nameParts: string[] = [];
|
||||
const summaryParts: string[] = [];
|
||||
const scriptContentParts: string[] = [];
|
||||
const genreArray: string[] = [];
|
||||
|
||||
let chapterJson: ChapterEntry[] = [];
|
||||
let volumeJson: VolumeEntry[] = [];
|
||||
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
switch (name) {
|
||||
case 'h1':
|
||||
state = ParsingState.InTitle;
|
||||
break;
|
||||
case 'a':
|
||||
if (attribs['href']?.startsWith('/profile/') && !novel.author) {
|
||||
state = ParsingState.InAuthor;
|
||||
} else if (state === ParsingState.InTags) {
|
||||
state = ParsingState.InTagLink;
|
||||
}
|
||||
break;
|
||||
case 'div':
|
||||
if (attribs['class'] === 'description') {
|
||||
state = ParsingState.InDescription;
|
||||
}
|
||||
break;
|
||||
case 'hr':
|
||||
if (state === ParsingState.InDescription) {
|
||||
summaryParts.push('\n\n---\n\n');
|
||||
}
|
||||
break;
|
||||
case 'br':
|
||||
if (state === ParsingState.InDescription) {
|
||||
summaryParts.push('\n\n');
|
||||
}
|
||||
break;
|
||||
case 'span':
|
||||
if (attribs['class']?.includes('tags')) {
|
||||
state = ParsingState.InTags;
|
||||
} else if (attribs['class']?.includes('label-sm')) {
|
||||
statusSpanCounter++;
|
||||
if (statusSpanCounter === 2) {
|
||||
state = ParsingState.InStatusSpan;
|
||||
statusText = '';
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'img':
|
||||
if (attribs['class']?.includes('thumbnail')) {
|
||||
novel.cover = attribs['src'];
|
||||
if (novel.cover && !isUrlAbsolute(novel.cover)) {
|
||||
novel.cover = baseUrl + novel.cover.slice(1);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'script':
|
||||
state = ParsingState.InScript;
|
||||
break;
|
||||
}
|
||||
},
|
||||
ontext(text) {
|
||||
const trimmedText = text.trim();
|
||||
if (!trimmedText && state !== ParsingState.InScript) return;
|
||||
|
||||
switch (state) {
|
||||
case ParsingState.InTitle:
|
||||
nameParts.push(text);
|
||||
break;
|
||||
case ParsingState.InAuthor:
|
||||
novel.author = trimmedText;
|
||||
break;
|
||||
case ParsingState.InDescription:
|
||||
summaryParts.push(text);
|
||||
break;
|
||||
case ParsingState.InStatusSpan:
|
||||
statusText = trimmedText;
|
||||
break;
|
||||
case ParsingState.InTagLink:
|
||||
genreArray.push(trimmedText);
|
||||
break;
|
||||
case ParsingState.InScript:
|
||||
scriptContentParts.push(text);
|
||||
break;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
switch (name) {
|
||||
case 'h1':
|
||||
if (state === ParsingState.InTitle) {
|
||||
novel.name = nameParts.join('').trim();
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
break;
|
||||
case 'a':
|
||||
if (state === ParsingState.InTagLink) {
|
||||
state = ParsingState.InTags;
|
||||
} else if (state === ParsingState.InAuthor) {
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
break;
|
||||
case 'p':
|
||||
if (state === ParsingState.InDescription) {
|
||||
summaryParts.push('\n\n');
|
||||
}
|
||||
break;
|
||||
case 'div':
|
||||
if (state === ParsingState.InDescription) {
|
||||
novel.summary = summaryParts
|
||||
.join('')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
summaryParts.length = 0;
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
break;
|
||||
case 'span':
|
||||
if (state === ParsingState.InTags) {
|
||||
novel.genres = genreArray.join(', ');
|
||||
state = ParsingState.Idle;
|
||||
} else if (state === ParsingState.InStatusSpan) {
|
||||
state = ParsingState.Idle;
|
||||
}
|
||||
break;
|
||||
case 'script':
|
||||
if (state === ParsingState.InScript) {
|
||||
state = ParsingState.Idle;
|
||||
const scriptContent = scriptContentParts.join('');
|
||||
const chapterMatch = scriptContent.match(
|
||||
/window\.chapters\s*=\s*(\[.*?\]);/,
|
||||
);
|
||||
const volumeMatch = scriptContent.match(
|
||||
/window\.volumes\s*=\s*(\[.*?\]);/,
|
||||
);
|
||||
|
||||
if (chapterMatch?.[1]) {
|
||||
chapterJson = JSON.parse(chapterMatch[1]);
|
||||
}
|
||||
if (volumeMatch?.[1]) {
|
||||
volumeJson = JSON.parse(volumeMatch[1]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
onend() {
|
||||
switch (statusText) {
|
||||
case 'ONGOING':
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
break;
|
||||
case 'HIATUS':
|
||||
novel.status = NovelStatus.OnHiatus;
|
||||
break;
|
||||
case 'COMPLETED':
|
||||
novel.status = NovelStatus.Completed;
|
||||
break;
|
||||
default:
|
||||
novel.status = NovelStatus.Unknown;
|
||||
}
|
||||
|
||||
novel.chapters = chapterJson.map((chapter: ChapterEntry) => {
|
||||
const matchingVolume = volumeJson.find(
|
||||
(volume: VolumeEntry) => volume.id === chapter.volumeId,
|
||||
);
|
||||
return {
|
||||
name: chapter.title,
|
||||
path: (() => {
|
||||
const parts = chapter.url.split('/');
|
||||
return `${parts[1]}/${parts[2]}/${parts[4]}/${parts[5]}`;
|
||||
})(),
|
||||
releaseTime: chapter.date,
|
||||
chapterNumber: chapter?.order,
|
||||
page: matchingVolume?.title,
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
|
||||
return novel as Plugin.NovelItem;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(this.site + chapterPath);
|
||||
const html = await result.text();
|
||||
|
||||
let state = ParsingState.Idle;
|
||||
let stateDepth = 0;
|
||||
let depth = 0;
|
||||
|
||||
const chapterHtmlParts: string[] = [];
|
||||
const notesHtmlParts: string[] = [];
|
||||
const beforeNotesParts: string[] = [];
|
||||
const afterNotesParts: string[] = [];
|
||||
let isBeforeChapter = true;
|
||||
|
||||
const match = html.match(/<style>\n\s+.(.+?){[^{]+?display: none;/);
|
||||
const hiddenClass = match?.[1]?.trim();
|
||||
let stateBeforeHidden: {
|
||||
state: ParsingState;
|
||||
depth: number;
|
||||
} | null = null;
|
||||
|
||||
type EscapeChar = '&' | '<' | '>' | '"' | "'";
|
||||
const escapeRegex = /[&<>"']/g;
|
||||
const escapeMap: Record<EscapeChar, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
const escapeHtml = (text: string): string =>
|
||||
escapeRegex.test(text)
|
||||
? ((escapeRegex.lastIndex = 0),
|
||||
text.replace(escapeRegex, char => escapeMap[char as EscapeChar]))
|
||||
: text;
|
||||
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
depth++;
|
||||
const classes = attribs['class'] || '';
|
||||
|
||||
if (
|
||||
state !== ParsingState.InHidden &&
|
||||
hiddenClass &&
|
||||
classes.includes(hiddenClass)
|
||||
) {
|
||||
stateBeforeHidden = { state: state, depth: stateDepth };
|
||||
state = ParsingState.InHidden;
|
||||
stateDepth = depth;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case ParsingState.Idle:
|
||||
if (classes.includes('chapter-content')) {
|
||||
state = ParsingState.InChapter;
|
||||
stateDepth = depth;
|
||||
isBeforeChapter = false;
|
||||
} else if (classes.includes('author-note-portlet')) {
|
||||
state = ParsingState.InNote;
|
||||
stateDepth = depth;
|
||||
}
|
||||
break;
|
||||
case ParsingState.InHidden:
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === ParsingState.InChapter || state === ParsingState.InNote) {
|
||||
let tag = `<${name}`;
|
||||
for (const attr in attribs) {
|
||||
const value = attribs[attr].replace(/"/g, '"');
|
||||
tag += ` ${attr}="${value}"`;
|
||||
}
|
||||
tag += '>';
|
||||
|
||||
if (state === ParsingState.InChapter) {
|
||||
chapterHtmlParts.push(tag);
|
||||
} else {
|
||||
notesHtmlParts.push(tag);
|
||||
}
|
||||
}
|
||||
},
|
||||
ontext(text) {
|
||||
switch (state) {
|
||||
case ParsingState.InChapter:
|
||||
chapterHtmlParts.push(escapeHtml(text));
|
||||
break;
|
||||
case ParsingState.InNote:
|
||||
notesHtmlParts.push(escapeHtml(text));
|
||||
break;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (depth === stateDepth) {
|
||||
switch (state) {
|
||||
case ParsingState.InHidden:
|
||||
if (!stateBeforeHidden) {
|
||||
state = ParsingState.Idle; // Attempt recovery
|
||||
stateDepth = 0;
|
||||
} else {
|
||||
state = stateBeforeHidden.state;
|
||||
stateDepth = stateBeforeHidden.depth;
|
||||
stateBeforeHidden = null;
|
||||
}
|
||||
depth--;
|
||||
return;
|
||||
case ParsingState.InChapter:
|
||||
chapterHtmlParts.push(`</div>`);
|
||||
state = ParsingState.Idle;
|
||||
stateDepth = 0;
|
||||
depth--;
|
||||
return;
|
||||
case ParsingState.InNote:
|
||||
const noteClass = `author-note-${isBeforeChapter ? 'before' : 'after'}`;
|
||||
const notesHtml = notesHtmlParts.join('').trim();
|
||||
const fullNote = `<div class="${noteClass}">${notesHtml}</div>`;
|
||||
if (isBeforeChapter) {
|
||||
beforeNotesParts.push(fullNote);
|
||||
} else {
|
||||
afterNotesParts.push(fullNote);
|
||||
}
|
||||
notesHtmlParts.length = 0;
|
||||
state = ParsingState.Idle;
|
||||
stateDepth = 0;
|
||||
depth--;
|
||||
return;
|
||||
}
|
||||
} else if (
|
||||
state === ParsingState.InChapter ||
|
||||
state === ParsingState.InNote
|
||||
) {
|
||||
if (!parser['isVoidElement'](name)) {
|
||||
const closingTag = `</${name}>`;
|
||||
if (state === ParsingState.InChapter) {
|
||||
chapterHtmlParts.push(closingTag);
|
||||
} else {
|
||||
notesHtmlParts.push(closingTag);
|
||||
}
|
||||
}
|
||||
}
|
||||
depth--;
|
||||
},
|
||||
});
|
||||
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
|
||||
return [
|
||||
beforeNotesParts.length > 0 ? beforeNotesParts.join('') : null,
|
||||
chapterHtmlParts.length > 0 ? chapterHtmlParts.join('').trim() : null,
|
||||
afterNotesParts.length > 0 ? afterNotesParts.join('') : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n<hr class="notes-separator">\n');
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
title: searchTerm,
|
||||
globalFilters: 'true',
|
||||
});
|
||||
const searchUrl = `${this.site}fictions/search?${params.toString()}`;
|
||||
const body = await fetchApi(searchUrl).then(r => r.text());
|
||||
|
||||
return this.parseNovels(body);
|
||||
}
|
||||
|
||||
filters = {
|
||||
'keyword': {
|
||||
'type': FilterTypes.TextInput,
|
||||
'label': 'Keyword (title or description)',
|
||||
'value': '',
|
||||
},
|
||||
'author': {
|
||||
'type': FilterTypes.TextInput,
|
||||
'label': 'Author',
|
||||
'value': '',
|
||||
},
|
||||
'genres': {
|
||||
'type': FilterTypes.ExcludableCheckboxGroup,
|
||||
'label': 'Genres',
|
||||
'value': {
|
||||
'include': [],
|
||||
'exclude': [],
|
||||
},
|
||||
'options': [
|
||||
{
|
||||
'label': 'Action',
|
||||
'value': 'action',
|
||||
},
|
||||
{
|
||||
'label': 'Adventure',
|
||||
'value': 'adventure',
|
||||
},
|
||||
{
|
||||
'label': 'Comedy',
|
||||
'value': 'comedy',
|
||||
},
|
||||
{
|
||||
'label': 'Contemporary',
|
||||
'value': 'contemporary',
|
||||
},
|
||||
{
|
||||
'label': 'Drama',
|
||||
'value': 'drama',
|
||||
},
|
||||
{
|
||||
'label': 'Fantasy',
|
||||
'value': 'fantasy',
|
||||
},
|
||||
{
|
||||
'label': 'Historical',
|
||||
'value': 'historical',
|
||||
},
|
||||
{
|
||||
'label': 'Horror',
|
||||
'value': 'horror',
|
||||
},
|
||||
{
|
||||
'label': 'Mystery',
|
||||
'value': 'mystery',
|
||||
},
|
||||
{
|
||||
'label': 'Psychological',
|
||||
'value': 'psychological',
|
||||
},
|
||||
{
|
||||
'label': 'Romance',
|
||||
'value': 'romance',
|
||||
},
|
||||
{
|
||||
'label': 'Satire',
|
||||
'value': 'satire',
|
||||
},
|
||||
{
|
||||
'label': 'Sci-fi',
|
||||
'value': 'sci_fi',
|
||||
},
|
||||
{
|
||||
'label': 'Short Story',
|
||||
'value': 'one_shot',
|
||||
},
|
||||
{
|
||||
'label': 'Tragedy',
|
||||
'value': 'tragedy',
|
||||
},
|
||||
],
|
||||
},
|
||||
'tags': {
|
||||
'type': FilterTypes.ExcludableCheckboxGroup,
|
||||
'label': 'Tags',
|
||||
'value': {
|
||||
'include': [],
|
||||
'exclude': [],
|
||||
},
|
||||
'options': [
|
||||
{
|
||||
'label': 'Anti-Hero Lead',
|
||||
'value': 'anti-hero_lead',
|
||||
},
|
||||
{
|
||||
'label': 'Artificial Intelligence',
|
||||
'value': 'artificial_intelligence',
|
||||
},
|
||||
{
|
||||
'label': 'Attractive Lead',
|
||||
'value': 'attractive_lead',
|
||||
},
|
||||
{
|
||||
'label': 'Cyberpunk',
|
||||
'value': 'cyberpunk',
|
||||
},
|
||||
{
|
||||
'label': 'Dungeon',
|
||||
'value': 'dungeon',
|
||||
},
|
||||
{
|
||||
'label': 'Dystopia',
|
||||
'value': 'dystopia',
|
||||
},
|
||||
{
|
||||
'label': 'Female Lead',
|
||||
'value': 'female_lead',
|
||||
},
|
||||
{
|
||||
'label': 'First Contact',
|
||||
'value': 'first_contact',
|
||||
},
|
||||
{
|
||||
'label': 'GameLit',
|
||||
'value': 'gamelit',
|
||||
},
|
||||
{
|
||||
'label': 'Gender Bender',
|
||||
'value': 'gender_bender',
|
||||
},
|
||||
{
|
||||
'label': 'Genetically Engineered',
|
||||
'value': 'genetically_engineered ',
|
||||
},
|
||||
{
|
||||
'label': 'Grimdark',
|
||||
'value': 'grimdark',
|
||||
},
|
||||
{
|
||||
'label': 'Hard Sci-fi',
|
||||
'value': 'hard_sci-fi',
|
||||
},
|
||||
{
|
||||
'label': 'Harem',
|
||||
'value': 'harem',
|
||||
},
|
||||
{
|
||||
'label': 'High Fantasy',
|
||||
'value': 'high_fantasy',
|
||||
},
|
||||
{
|
||||
'label': 'LitRPG',
|
||||
'value': 'litrpg',
|
||||
},
|
||||
{
|
||||
'label': 'Low Fantasy',
|
||||
'value': 'low_fantasy',
|
||||
},
|
||||
{
|
||||
'label': 'Magic',
|
||||
'value': 'magic',
|
||||
},
|
||||
{
|
||||
'label': 'Male Lead',
|
||||
'value': 'male_lead',
|
||||
},
|
||||
{
|
||||
'label': 'Martial Arts',
|
||||
'value': 'martial_arts',
|
||||
},
|
||||
{
|
||||
'label': 'Multiple Lead Characters',
|
||||
'value': 'multiple_lead',
|
||||
},
|
||||
{
|
||||
'label': 'Mythos',
|
||||
'value': 'mythos',
|
||||
},
|
||||
{
|
||||
'label': 'Non-Human Lead',
|
||||
'value': 'non-human_lead',
|
||||
},
|
||||
{
|
||||
'label': 'Portal Fantasy / Isekai',
|
||||
'value': 'summoned_hero',
|
||||
},
|
||||
{
|
||||
'label': 'Post Apocalyptic',
|
||||
'value': 'post_apocalyptic',
|
||||
},
|
||||
{
|
||||
'label': 'Progression',
|
||||
'value': 'progression',
|
||||
},
|
||||
{
|
||||
'label': 'Reader Interactive',
|
||||
'value': 'reader_interactive',
|
||||
},
|
||||
{
|
||||
'label': 'Reincarnation',
|
||||
'value': 'reincarnation',
|
||||
},
|
||||
{
|
||||
'label': 'Ruling Class',
|
||||
'value': 'ruling_class',
|
||||
},
|
||||
{
|
||||
'label': 'School Life',
|
||||
'value': 'school_life',
|
||||
},
|
||||
{
|
||||
'label': 'Secret Identity',
|
||||
'value': 'secret_identity',
|
||||
},
|
||||
{
|
||||
'label': 'Slice of Life',
|
||||
'value': 'slice_of_life',
|
||||
},
|
||||
{
|
||||
'label': 'Soft Sci-fi',
|
||||
'value': 'soft_sci-fi',
|
||||
},
|
||||
{
|
||||
'label': 'Space Opera',
|
||||
'value': 'space_opera',
|
||||
},
|
||||
{
|
||||
'label': 'Sports',
|
||||
'value': 'sports',
|
||||
},
|
||||
{
|
||||
'label': 'Steampunk',
|
||||
'value': 'steampunk',
|
||||
},
|
||||
{
|
||||
'label': 'Strategy',
|
||||
'value': 'strategy',
|
||||
},
|
||||
{
|
||||
'label': 'Strong Lead',
|
||||
'value': 'strong_lead',
|
||||
},
|
||||
{
|
||||
'label': 'Super Heroes',
|
||||
'value': 'super_heroes',
|
||||
},
|
||||
{
|
||||
'label': 'Supernatural',
|
||||
'value': 'supernatural',
|
||||
},
|
||||
{
|
||||
'label': 'Technologically Engineered',
|
||||
'value': 'technologically_engineered',
|
||||
},
|
||||
{
|
||||
'label': 'Time Loop',
|
||||
'value': 'loop',
|
||||
},
|
||||
{
|
||||
'label': 'Time Travel',
|
||||
'value': 'time_travel',
|
||||
},
|
||||
{
|
||||
'label': 'Urban Fantasy',
|
||||
'value': 'urban_fantasy',
|
||||
},
|
||||
{
|
||||
'label': 'Villainous Lead',
|
||||
'value': 'villainous_lead',
|
||||
},
|
||||
{
|
||||
'label': 'Virtual Reality',
|
||||
'value': 'virtual_reality',
|
||||
},
|
||||
{
|
||||
'label': 'War and Military',
|
||||
'value': 'war_and_military',
|
||||
},
|
||||
{
|
||||
'label': 'Wuxia',
|
||||
'value': 'wuxia',
|
||||
},
|
||||
{
|
||||
'label': 'Xianxia',
|
||||
'value': 'xianxia',
|
||||
},
|
||||
],
|
||||
},
|
||||
'content_warnings': {
|
||||
'type': FilterTypes.ExcludableCheckboxGroup,
|
||||
'label': 'Content Warnings',
|
||||
'value': {
|
||||
'include': [],
|
||||
'exclude': [],
|
||||
},
|
||||
'options': [
|
||||
{
|
||||
'label': 'Profanity',
|
||||
'value': 'profanity',
|
||||
},
|
||||
{
|
||||
'label': 'Sexual Content',
|
||||
'value': 'sexuality',
|
||||
},
|
||||
{
|
||||
'label': 'Graphic Violence',
|
||||
'value': 'graphic_violence',
|
||||
},
|
||||
{
|
||||
'label': 'Sensitive Content',
|
||||
'value': 'sensitive',
|
||||
},
|
||||
{
|
||||
'label': 'AI-Assisted Content',
|
||||
'value': 'ai_assisted',
|
||||
},
|
||||
{
|
||||
'label': 'AI-Generated Content',
|
||||
'value': 'ai_generated',
|
||||
},
|
||||
],
|
||||
},
|
||||
'minPages': {
|
||||
'type': FilterTypes.TextInput,
|
||||
'label': 'Min Pages',
|
||||
'value': '0',
|
||||
},
|
||||
'maxPages': {
|
||||
'type': FilterTypes.TextInput,
|
||||
'label': 'Max Pages',
|
||||
'value': '20000',
|
||||
},
|
||||
'minRating': {
|
||||
'type': FilterTypes.TextInput,
|
||||
'label': 'Min Rating (0.0 - 5.0)',
|
||||
'value': '0.0',
|
||||
},
|
||||
'maxRating': {
|
||||
'type': FilterTypes.TextInput,
|
||||
'label': 'Max Rating (0.0 - 5.0)',
|
||||
'value': '5.0',
|
||||
},
|
||||
'status': {
|
||||
'type': FilterTypes.Picker,
|
||||
'label': 'Status',
|
||||
'value': 'ALL',
|
||||
'options': [
|
||||
{
|
||||
'label': 'All',
|
||||
'value': 'ALL',
|
||||
},
|
||||
{
|
||||
'label': 'Completed',
|
||||
'value': 'COMPLETED',
|
||||
},
|
||||
{
|
||||
'label': 'Dropped',
|
||||
'value': 'DROPPED',
|
||||
},
|
||||
{
|
||||
'label': 'Ongoing',
|
||||
'value': 'ONGOING',
|
||||
},
|
||||
{
|
||||
'label': 'Hiatus',
|
||||
'value': 'HIATUS',
|
||||
},
|
||||
{
|
||||
'label': 'Stub',
|
||||
'value': 'STUB',
|
||||
},
|
||||
],
|
||||
},
|
||||
'orderBy': {
|
||||
'type': FilterTypes.Picker,
|
||||
'label': 'Order by',
|
||||
'value': 'relevance',
|
||||
'options': [
|
||||
{
|
||||
'label': 'Relevance',
|
||||
'value': 'relevance',
|
||||
},
|
||||
{
|
||||
'label': 'Popularity',
|
||||
'value': 'popularity',
|
||||
},
|
||||
{
|
||||
'label': 'Average Rating',
|
||||
'value': 'rating',
|
||||
},
|
||||
{
|
||||
'label': 'Last Update',
|
||||
'value': 'last_update',
|
||||
},
|
||||
{
|
||||
'label': 'Release Date',
|
||||
'value': 'release_date',
|
||||
},
|
||||
{
|
||||
'label': 'Followers',
|
||||
'value': 'followers',
|
||||
},
|
||||
{
|
||||
'label': 'Number of Pages',
|
||||
'value': 'length',
|
||||
},
|
||||
{
|
||||
'label': 'Views',
|
||||
'value': 'views',
|
||||
},
|
||||
{
|
||||
'label': 'Title',
|
||||
'value': 'title',
|
||||
},
|
||||
{
|
||||
'label': 'Author',
|
||||
'value': 'author',
|
||||
},
|
||||
],
|
||||
},
|
||||
'dir': {
|
||||
'type': FilterTypes.Picker,
|
||||
'label': 'Direction',
|
||||
'value': 'desc',
|
||||
'options': [
|
||||
{
|
||||
'label': 'Ascending',
|
||||
'value': 'asc',
|
||||
},
|
||||
{
|
||||
'label': 'Descending',
|
||||
'value': 'desc',
|
||||
},
|
||||
],
|
||||
},
|
||||
'type': {
|
||||
'type': FilterTypes.Picker,
|
||||
'label': 'Type',
|
||||
'value': 'ALL',
|
||||
'options': [
|
||||
{
|
||||
'label': 'All',
|
||||
'value': 'ALL',
|
||||
},
|
||||
{
|
||||
'label': 'Fan Fiction',
|
||||
'value': 'fanfiction',
|
||||
},
|
||||
{
|
||||
'label': 'Original',
|
||||
'value': 'original',
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new RoyalRoad();
|
||||
|
||||
type ChapterEntry = {
|
||||
id: number;
|
||||
volumeId: number;
|
||||
title: string;
|
||||
date: string;
|
||||
order: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type VolumeEntry = {
|
||||
id: number;
|
||||
title: string;
|
||||
cover: string;
|
||||
order: number;
|
||||
};
|
||||
|
||||
enum ParsingState {
|
||||
Idle,
|
||||
InTitle,
|
||||
InAuthor,
|
||||
InDescription,
|
||||
InTags,
|
||||
InTagLink,
|
||||
InStatusSpan,
|
||||
InScript,
|
||||
InNote,
|
||||
InChapter,
|
||||
InHidden,
|
||||
Novel,
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class ScribbleHubPlugin implements Plugin.PluginBase {
|
||||
id = 'scribblehub';
|
||||
name = 'Scribble Hub';
|
||||
icon = 'src/en/scribblehub/icon.png';
|
||||
site = 'https://www.scribblehub.com/';
|
||||
version = '1.0.2';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.search_main_box').each((i, el) => {
|
||||
const novelName = loadedCheerio(el).find('.search_title > a').text();
|
||||
const novelCover = loadedCheerio(el)
|
||||
.find('.search_img > img')
|
||||
.attr('src');
|
||||
const novelUrl = loadedCheerio(el).find('.search_title > a').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
novels.push(novel);
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = `${this.site}`;
|
||||
if (showLatestNovels) {
|
||||
url += `latest-series/?pg=${page}`;
|
||||
} else if (filters) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.genres.value.include?.length) {
|
||||
params.append('gi', filters.genres.value.include.join(','));
|
||||
}
|
||||
if (
|
||||
filters.genres.value.include?.length ||
|
||||
filters.genres.value.exclude?.length
|
||||
) {
|
||||
params.append('mgi', filters.genre_operator.value);
|
||||
}
|
||||
if (filters.genres.value.exclude?.length) {
|
||||
params.append('ge', filters.genres.value.exclude.join(','));
|
||||
}
|
||||
if (filters.content_warning.value.include?.length) {
|
||||
params.append('cti', filters.content_warning.value.include.join(','));
|
||||
}
|
||||
if (
|
||||
filters.content_warning.value.include?.length ||
|
||||
filters.content_warning.value.exclude?.length
|
||||
) {
|
||||
params.append('mct', filters.content_warning_operator.value);
|
||||
}
|
||||
if (filters.content_warning.value.exclude?.length) {
|
||||
params.append('cte', filters.content_warning.value.exclude.join(','));
|
||||
}
|
||||
params.append('cp', filters.storyStatus.value);
|
||||
params.append('sort', filters.sort.value);
|
||||
params.append('order', filters.order.value);
|
||||
params.append('pg', page.toString());
|
||||
url += `series-finder/?sf=1&${params.toString()}`;
|
||||
} else {
|
||||
url += `series-finder/?sf=1&sort=ratings&order=desc&pg=${page}`;
|
||||
}
|
||||
|
||||
const body = await fetchApi(url).then(result => result.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const result = await fetchApi(this.site + novelPath);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.fic_title').text() || 'Untitled',
|
||||
cover: loadedCheerio('.fic_image > img').attr('src'),
|
||||
summary: loadedCheerio('.wi_fic_desc').text(),
|
||||
author: loadedCheerio('.auth_name_fic').text(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
novel.genres = loadedCheerio('.fic_genre')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
novel.status = loadedCheerio('.rnd_stats').next().text().includes('Ongoing')
|
||||
? 'Ongoing'
|
||||
: 'Completed';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('action', 'wi_getreleases_pagination');
|
||||
formData.append('pagenum', '-1');
|
||||
formData.append('mypostid', novelPath.split('/')[1]);
|
||||
|
||||
const data = await fetchApi(`${this.site}wp-admin/admin-ajax.php`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const text = await data.text();
|
||||
|
||||
loadedCheerio = parseHTML(text);
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
const parseISODate = (date: string) => {
|
||||
if (date.includes('ago')) {
|
||||
const dayJSDate = dayjs(new Date()); // today
|
||||
const timeAgo = date.match(/\d+/)?.[0] || '';
|
||||
const timeAgoInt = parseInt(timeAgo, 10);
|
||||
|
||||
if (!timeAgo) return null; // there is no number!
|
||||
|
||||
if (date.includes('hours ago') || date.includes('hour ago')) {
|
||||
dayJSDate.subtract(timeAgoInt, 'hours'); // go back N hours
|
||||
}
|
||||
|
||||
if (date.includes('days ago') || date.includes('day ago')) {
|
||||
dayJSDate.subtract(timeAgoInt, 'days'); // go back N days
|
||||
}
|
||||
|
||||
if (date.includes('months ago') || date.includes('month ago')) {
|
||||
dayJSDate.subtract(timeAgoInt, 'months'); // go back N months
|
||||
}
|
||||
|
||||
return dayJSDate.toISOString();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
loadedCheerio('.toc_w').each((i, el) => {
|
||||
const chapterName = loadedCheerio(el).find('.toc_a').text();
|
||||
const releaseDate = loadedCheerio(el).find('.fic_date_pub').text();
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href');
|
||||
|
||||
if (!chapterUrl) return;
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
releaseTime: parseISODate(releaseDate),
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapter.reverse();
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(this.site + chapterPath);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const chapterText = loadedCheerio('div.chp_raw').html() || '';
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}?s=${encodeURIComponent(searchTerm)}&post_type=fictionposts`;
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Sort Results By',
|
||||
value: 'ratings',
|
||||
options: [
|
||||
{ label: 'Chapters', value: 'chapters' },
|
||||
{ label: 'Chapters per Week', value: 'frequency' },
|
||||
{ label: 'Date Added', value: 'dateadded' },
|
||||
{ label: 'Favorites', value: 'favorites' },
|
||||
{ label: 'Last Updated', value: 'lastchdate' },
|
||||
{ label: 'Number of Ratings', value: 'numofrate' },
|
||||
{ label: 'Pages', value: 'pages' },
|
||||
{ label: 'Pageviews', value: 'pageviews' },
|
||||
{ label: 'Ratings', value: 'ratings' },
|
||||
{ label: 'Readers', value: 'readers' },
|
||||
{ label: 'Reviews', value: 'reviews' },
|
||||
{ label: 'Total Words', value: 'totalwords' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
order: {
|
||||
label: 'Order By',
|
||||
value: 'desc',
|
||||
options: [
|
||||
{ label: 'Descending', value: 'desc' },
|
||||
{ label: 'Ascending', value: 'asc' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
storyStatus: {
|
||||
label: 'Story Status',
|
||||
value: 'all',
|
||||
options: [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Completed', value: 'completed' },
|
||||
{ label: 'Ongoing', value: 'ongoing' },
|
||||
{ label: 'Hiatus', value: 'hiatus' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genre_operator: {
|
||||
value: 'and',
|
||||
label: 'Genres (And/Or)',
|
||||
options: [
|
||||
{ label: 'And', value: 'and' },
|
||||
{ label: 'Or', value: 'or' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genres: {
|
||||
label: 'Genres',
|
||||
value: {
|
||||
include: [],
|
||||
exclude: [],
|
||||
},
|
||||
options: [
|
||||
{ label: 'Action', value: '9' },
|
||||
{ label: 'Adult', value: '902' },
|
||||
{ label: 'Adventure', value: '8' },
|
||||
{ label: 'Boys Love', value: '891' },
|
||||
{ label: 'Comedy', value: '7' },
|
||||
{ label: 'Drama', value: '903' },
|
||||
{ label: 'Ecchi', value: '904' },
|
||||
{ label: 'Fanfiction', value: '38' },
|
||||
{ label: 'Fantasy', value: '19' },
|
||||
{ label: 'Gender Bender', value: '905' },
|
||||
{ label: 'Girls Love', value: '892' },
|
||||
{ label: 'Harem', value: '1015' },
|
||||
{ label: 'Historical', value: '21' },
|
||||
{ label: 'Horror', value: '22' },
|
||||
{ label: 'Isekai', value: '37' },
|
||||
{ label: 'Josei', value: '906' },
|
||||
{ label: 'LitRPG', value: '1180' },
|
||||
{ label: 'Martial Arts', value: '907' },
|
||||
{ label: 'Mature', value: '20' },
|
||||
{ label: 'Mecha', value: '908' },
|
||||
{ label: 'Mystery', value: '909' },
|
||||
{ label: 'Psychological', value: '910' },
|
||||
{ label: 'Romance', value: '6' },
|
||||
{ label: 'School Life', value: '911' },
|
||||
{ label: 'Sci-fi', value: '912' },
|
||||
{ label: 'Seinen', value: '913' },
|
||||
{ label: 'Slice of Life', value: '914' },
|
||||
{ label: 'Smut', value: '915' },
|
||||
{ label: 'Sports', value: '916' },
|
||||
{ label: 'Supernatural', value: '5' },
|
||||
{ label: 'Tragedy', value: '901' },
|
||||
],
|
||||
type: FilterTypes.ExcludableCheckboxGroup,
|
||||
},
|
||||
content_warning_operator: {
|
||||
value: 'and',
|
||||
label: 'Mature Content (And/Or)',
|
||||
options: [
|
||||
{ label: 'And', value: 'and' },
|
||||
{ label: 'Or', value: 'or' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
content_warning: {
|
||||
value: {
|
||||
include: [],
|
||||
exclude: [],
|
||||
},
|
||||
label: 'Mature Content',
|
||||
options: [
|
||||
{ label: 'Gore', value: '48' },
|
||||
{ label: 'Sexual Content', value: '50' },
|
||||
{ label: 'Strong Language', value: '49' },
|
||||
],
|
||||
type: FilterTypes.ExcludableCheckboxGroup,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new ScribbleHubPlugin();
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class VyNovel implements Plugin.PluginBase {
|
||||
id = 'vynovel';
|
||||
name = 'VyNovel';
|
||||
site = 'https://vynovel.com';
|
||||
version = '1.0.0';
|
||||
icon = 'src/en/vynovel/icon.png';
|
||||
|
||||
async fetchNovels(
|
||||
page: number,
|
||||
{
|
||||
filters,
|
||||
showLatestNovels,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
searchTerm?: string,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const data = new URLSearchParams({
|
||||
sort: showLatestNovels ? 'updated_at' : filters?.sort?.value || 'viewed',
|
||||
page,
|
||||
});
|
||||
if (searchTerm) data.append('q', searchTerm);
|
||||
|
||||
const url = this.site + '/search?' + data.toString();
|
||||
|
||||
const body = await fetchApi(url).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
loadedCheerio('div[class="comic-item"] > a').each((index, element) => {
|
||||
const name = loadedCheerio(element)
|
||||
.find('div[class="comic-title"]')
|
||||
.text()
|
||||
?.trim();
|
||||
const cover =
|
||||
loadedCheerio(element)
|
||||
.find('div[class="comic-image lozad "]')
|
||||
.attr('data-background-image') || defaultCover;
|
||||
const url = loadedCheerio(element).attr('href');
|
||||
|
||||
if (!name || !url) return;
|
||||
|
||||
novels.push({ name, cover, path: url.replace('/novel/', '') });
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
popularNovels = this.fetchNovels;
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const defaultOptions: any = {
|
||||
filters: undefined,
|
||||
showLatestNovels: false,
|
||||
};
|
||||
return this.fetchNovels(page, defaultOptions, searchTerm);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const body = await fetchApi(this.resolveUrl(novelPath, true)).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h1[class="title"]').text().trim(),
|
||||
cover:
|
||||
loadedCheerio('div[class="img-manga"] > img').attr('src') ||
|
||||
defaultCover,
|
||||
summary: loadedCheerio('div[class="summary"] > p[class="content"]')
|
||||
.text()
|
||||
.trim(),
|
||||
author: loadedCheerio('div[class="col-md-7"] > p:nth-child(5) > a')
|
||||
.text()
|
||||
.trim(),
|
||||
status:
|
||||
loadedCheerio('span[class="text-ongoing"]').text() === 'Ongoing'
|
||||
? NovelStatus.Ongoing
|
||||
: NovelStatus.Completed,
|
||||
};
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
const totalChapters = loadedCheerio('div[class="list-group"] > a').length;
|
||||
|
||||
loadedCheerio('div[class="list-group"] > a').each(
|
||||
(chapterIndex, element) => {
|
||||
const name = loadedCheerio(element).find('span').text().trim();
|
||||
const id = loadedCheerio(element).attr('id')?.replace(/\D/g, '');
|
||||
if (!name || !id) return;
|
||||
|
||||
const releaseDate = loadedCheerio(element).find('p').text();
|
||||
chapters.push({
|
||||
name,
|
||||
path: novelPath + '/' + id,
|
||||
releaseTime: this.parseDate(releaseDate?.trim()),
|
||||
chapterNumber: totalChapters - chapterIndex,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
novel.chapters = chapters.reverse();
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchApi(this.resolveUrl(chapterPath)).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const chapterText = loadedCheerio('.content').html();
|
||||
return chapterText || '';
|
||||
}
|
||||
|
||||
parseDate = (date = '') => {
|
||||
if (!date) return null;
|
||||
if (date.includes('ago')) {
|
||||
const [value, type] = date.split(' ');
|
||||
if (!value || !type) return null;
|
||||
|
||||
switch (type.toLowerCase()) {
|
||||
case 'minutes': {
|
||||
const minutes = parseInt(value, 10);
|
||||
date = Date.now() - minutes * 60 * 1000;
|
||||
break;
|
||||
}
|
||||
case 'hour':
|
||||
case 'hours': {
|
||||
const hours = parseInt(value, 10);
|
||||
date = Date.now() - hours * 60 * 60 * 1000;
|
||||
break;
|
||||
}
|
||||
case 'day':
|
||||
case 'days': {
|
||||
const days = parseInt(value, 10);
|
||||
date = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.log(date);
|
||||
date = undefined;
|
||||
}
|
||||
return dayjs(date).format('LLL');
|
||||
}
|
||||
return date;
|
||||
};
|
||||
|
||||
resolveUrl = (path: string, isNovel?: boolean) =>
|
||||
this.site + (isNovel ? '/novel/' : '/read/') + path;
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Sort By:',
|
||||
value: 'viewed',
|
||||
options: [
|
||||
{ label: 'Viewed', value: 'viewed' },
|
||||
{ label: 'Scored', value: 'scored' },
|
||||
{ label: 'Newest', value: 'created_at' },
|
||||
{ label: 'Latest Update', value: 'updated_at' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new VyNovel();
|
||||
@@ -0,0 +1,340 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { storage } from '@/lib/storage';
|
||||
|
||||
class Webnovel implements Plugin.PluginBase {
|
||||
id = 'webnovel';
|
||||
name = 'Webnovel';
|
||||
version = '1.0.3';
|
||||
icon = 'src/en/webnovel/icon.png';
|
||||
site = 'https://www.webnovel.com';
|
||||
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',
|
||||
};
|
||||
imageRequestInit?: Plugin.ImageRequestInit | undefined = {
|
||||
headers: {
|
||||
'referrer': this.site,
|
||||
},
|
||||
};
|
||||
hideLocked = storage.get('hideLocked');
|
||||
pluginSettings = {
|
||||
hideLocked: {
|
||||
value: '',
|
||||
label: 'Hide locked chapters',
|
||||
type: 'Switch',
|
||||
},
|
||||
};
|
||||
|
||||
async parseNovels(
|
||||
loadedCheerio: CheerioAPI,
|
||||
category_bool: boolean,
|
||||
search_bool: boolean,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const selector = category_bool
|
||||
? '.j_category_wrapper'
|
||||
: search_bool
|
||||
? '.j_list_container'
|
||||
: '';
|
||||
const attribute = category_bool
|
||||
? 'data-original'
|
||||
: search_bool
|
||||
? 'src'
|
||||
: '';
|
||||
|
||||
return loadedCheerio(`${selector} li`)
|
||||
.map((i_, ele) => {
|
||||
const novelName =
|
||||
loadedCheerio(ele).find('.g_thumb').attr('title') || 'No Title Found';
|
||||
const novelCover = loadedCheerio(ele)
|
||||
.find('.g_thumb > img')
|
||||
.attr(attribute);
|
||||
const novelPath = loadedCheerio(ele).find('.g_thumb').attr('href');
|
||||
|
||||
if (!novelPath) return null;
|
||||
|
||||
return {
|
||||
name: novelName,
|
||||
cover: 'https:' + novelCover,
|
||||
path: novelPath,
|
||||
};
|
||||
})
|
||||
.get()
|
||||
.filter(novel => novel !== null);
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (filters?.fanfic_search.value) {
|
||||
return this.searchNovelsInternal(
|
||||
filters.fanfic_search.value,
|
||||
pageNo,
|
||||
'fanfic',
|
||||
);
|
||||
}
|
||||
let url = this.site + '/stories/';
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (showLatestNovels) {
|
||||
url += `novel?orderBy=5&pageIndex=${pageNo}`;
|
||||
} else if (filters) {
|
||||
if (filters.genres_gender.value === '1') {
|
||||
if (filters.genres_male.value !== '1') {
|
||||
url += filters.genres_male.value;
|
||||
} else {
|
||||
url += 'novel';
|
||||
params.append('gender', '1');
|
||||
}
|
||||
} else if (filters.genres_gender.value === '2') {
|
||||
if (filters.genres_female.value !== '2') {
|
||||
url += filters.genres_female.value;
|
||||
} else {
|
||||
url += 'novel';
|
||||
params.append('gender', '2');
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.type.value !== '3') {
|
||||
params.append('sourceType', filters.type.value);
|
||||
} else {
|
||||
params.append('translateMode', '3');
|
||||
params.append('sourceType', '1');
|
||||
}
|
||||
|
||||
params.append('bookStatus', filters.status.value);
|
||||
params.append('orderBy', filters.sort.value);
|
||||
params.append('pageIndex', pageNo.toString());
|
||||
|
||||
url += '?' + params.toString();
|
||||
} else {
|
||||
url += `novel?orderBy=1&pageIndex=${pageNo}`;
|
||||
}
|
||||
|
||||
const result = await fetchApi(url, {
|
||||
headers: this.headers,
|
||||
});
|
||||
const body = await result.text();
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
return this.parseNovels(loadedCheerio, true, false);
|
||||
}
|
||||
|
||||
async parseChapters(novelPath: string): Promise<Plugin.ChapterItem[]> {
|
||||
const url = this.site + novelPath + '/catalog';
|
||||
const result = await fetchApi(url, {
|
||||
headers: this.headers,
|
||||
});
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('.volume-item').each((i_v_, ele_v) => {
|
||||
const originalVolumeName = loadedCheerio(ele_v).first().text().trim();
|
||||
const volumeNameMatch = originalVolumeName.match(/Volume\s(\d+)/);
|
||||
const volumeName = volumeNameMatch
|
||||
? `Volume ${volumeNameMatch[1]}`
|
||||
: 'Unknown Volume';
|
||||
|
||||
loadedCheerio(ele_v)
|
||||
.find('li')
|
||||
.each((i_c_, ele_c) => {
|
||||
const chapterName =
|
||||
`${volumeName}: ` +
|
||||
(loadedCheerio(ele_c).find('a').attr('title')?.trim() ||
|
||||
'No Title Found');
|
||||
const chapterPath = loadedCheerio(ele_c).find('a').attr('href');
|
||||
const locked = loadedCheerio(ele_c).find('svg').length;
|
||||
|
||||
if (chapterPath && !(locked && this.hideLocked)) {
|
||||
chapters.push({
|
||||
name: locked ? `${chapterName} 🔒` : chapterName,
|
||||
path: chapterPath,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return chapters;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const url = this.site + novelPath;
|
||||
const result = await fetchApi(url, {
|
||||
headers: this.headers,
|
||||
});
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.g_thumb > img').attr('alt') || 'No Title Found',
|
||||
cover: 'https:' + loadedCheerio('.g_thumb > img').attr('src'),
|
||||
genres: loadedCheerio('.det-hd-detail > .det-hd-tag').attr('title') || '',
|
||||
summary:
|
||||
loadedCheerio('.j_synopsis > p')
|
||||
.find('br')
|
||||
.replaceWith('\n')
|
||||
.end()
|
||||
.text()
|
||||
.trim() || 'No Summary Found',
|
||||
author:
|
||||
loadedCheerio('.det-info .c_s')
|
||||
.filter((i_, ele) => {
|
||||
return loadedCheerio(ele).text().trim() === 'Author:';
|
||||
})
|
||||
.next()
|
||||
.text()
|
||||
.trim() || 'No Author Found',
|
||||
status:
|
||||
loadedCheerio('.det-hd-detail svg')
|
||||
.filter((i_, ele) => {
|
||||
return loadedCheerio(ele).attr('title') === 'Status';
|
||||
})
|
||||
.next()
|
||||
.text()
|
||||
.trim() || 'Unknown Status',
|
||||
chapters: await this.parseChapters(novelPath),
|
||||
};
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const url = this.site + chapterPath;
|
||||
const result = await fetchApi(url, {
|
||||
headers: this.headers,
|
||||
});
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const bloatElements = ['.para-comment'];
|
||||
bloatElements.forEach(tag => loadedCheerio(tag).remove());
|
||||
|
||||
return (
|
||||
loadedCheerio('.cha-tit').html()! + loadedCheerio('.cha-words').html()!
|
||||
);
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
return this.searchNovelsInternal(searchTerm, pageNo);
|
||||
}
|
||||
|
||||
async searchNovelsInternal(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
type?: string,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
searchTerm = searchTerm.replace(/\s+/g, '+');
|
||||
|
||||
const url = `${this.site}/search?keywords=${encodeURIComponent(searchTerm)}&pageIndex=${pageNo}${type ? `&type=${type}` : ''}`;
|
||||
const result = await fetchApi(url, {
|
||||
headers: this.headers,
|
||||
});
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
return this.parseNovels(loadedCheerio, false, true);
|
||||
}
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Sort Results By',
|
||||
value: '1',
|
||||
options: [
|
||||
{ label: 'Popular', value: '1' },
|
||||
{ label: 'Recommended', value: '2' },
|
||||
{ label: 'Most Collections', value: '3' },
|
||||
{ label: 'Rating', value: '4' },
|
||||
{ label: 'Time Updated', value: '5' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
status: {
|
||||
label: 'Content Status',
|
||||
value: '0',
|
||||
options: [
|
||||
{ label: 'All', value: '0' },
|
||||
{ label: 'Completed', value: '2' },
|
||||
{ label: 'Ongoing', value: '1' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genres_gender: {
|
||||
label: 'Genres (Male/Female)',
|
||||
value: '1',
|
||||
options: [
|
||||
{ label: 'Male', value: '1' },
|
||||
{ label: 'Female', value: '2' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genres_male: {
|
||||
label: 'Male Genres',
|
||||
value: '1',
|
||||
options: [
|
||||
{ label: 'All', value: '1' },
|
||||
{ label: 'Action', value: 'novel-action-male' },
|
||||
{ label: 'Animation, Comics, Games', value: 'novel-acg-male' },
|
||||
{ label: 'Eastern', value: 'novel-eastern-male' },
|
||||
{ label: 'Fantasy', value: 'novel-fantasy-male' },
|
||||
{ label: 'Games', value: 'novel-games-male' },
|
||||
{ label: 'History', value: 'novel-history-male' },
|
||||
{ label: 'Horror', value: 'novel-horror-male' },
|
||||
{ label: 'Realistic', value: 'novel-realistic-male' },
|
||||
{ label: 'Sci-fi', value: 'novel-scifi-male' },
|
||||
{ label: 'Sports', value: 'novel-sports-male' },
|
||||
{ label: 'Urban', value: 'novel-urban-male' },
|
||||
{ label: 'War', value: 'novel-war-male' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genres_female: {
|
||||
label: 'Female Genres',
|
||||
value: '2',
|
||||
options: [
|
||||
{ label: 'All', value: '2' },
|
||||
{ label: 'Fantasy', value: 'novel-fantasy-female' },
|
||||
{ label: 'General', value: 'novel-general-female' },
|
||||
{ label: 'History', value: 'novel-history-female' },
|
||||
{ label: 'LGBT+', value: 'novel-lgbt-female' },
|
||||
{ label: 'Sci-fi', value: 'novel-scifi-female' },
|
||||
{ label: 'Teen', value: 'novel-teen-female' },
|
||||
{ label: 'Urban', value: 'novel-urban-female' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
type: {
|
||||
label: 'Content Type',
|
||||
value: '0',
|
||||
options: [
|
||||
{ label: 'All', value: '0' },
|
||||
{ label: 'Translate', value: '1' },
|
||||
{ label: 'Original', value: '2' },
|
||||
{ label: 'MTL (Machine Translation)', value: '3' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
fanfic_search: {
|
||||
label: 'Search fanfics (Overrides other filters)',
|
||||
value: '',
|
||||
type: FilterTypes.TextInput,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new Webnovel();
|
||||
@@ -0,0 +1,298 @@
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
|
||||
class WTRLAB implements Plugin.PluginBase {
|
||||
id = 'WTRLAB';
|
||||
name = 'WTR-LAB';
|
||||
site = 'https://wtr-lab.com/';
|
||||
version = '1.0.1';
|
||||
icon = 'src/en/wtrlab/icon.png';
|
||||
sourceLang = 'en/';
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = this.site + this.sourceLang + 'novel-list?';
|
||||
link += `orderBy=${filters.order.value}`;
|
||||
link += `&order=${filters.sort.value}`;
|
||||
link += `&filter=${filters.storyStatus.value}`;
|
||||
link += `&page=${page}`; //TODO Genre & Advance Searching Filter. Ez to implement, too much manual work, too lazy.
|
||||
|
||||
if (showLatestNovels) {
|
||||
const response = await fetchApi(this.site + 'api/home/recent', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ page: page }),
|
||||
});
|
||||
|
||||
const recentNovel: JsonNovel = await response.json();
|
||||
|
||||
// Parse novels from JSON
|
||||
const novels: Plugin.NovelItem[] = recentNovel.data.map(
|
||||
(datum: Datum) => ({
|
||||
name: datum.serie.data.title || '',
|
||||
cover: datum.serie.data.image,
|
||||
path:
|
||||
this.sourceLang +
|
||||
'serie-' +
|
||||
datum.serie.raw_id +
|
||||
'/' +
|
||||
datum.serie.slug || '',
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
} else {
|
||||
const body = await fetchApi(link).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const novels: Plugin.NovelItem[] = loadedCheerio('.serie-item')
|
||||
.map((index, element) => ({
|
||||
name:
|
||||
loadedCheerio(element)
|
||||
.find('.title-wrap > a')
|
||||
.text()
|
||||
.replace(loadedCheerio(element).find('.rawtitle').text(), '') ||
|
||||
'',
|
||||
cover: loadedCheerio(element).find('img').attr('src'),
|
||||
path: loadedCheerio(element).find('a').attr('href') || '',
|
||||
}))
|
||||
.get()
|
||||
.filter(novel => novel.name && novel.path);
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const body = await fetchApi(this.site + novelPath).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h1.text-uppercase').text(),
|
||||
cover: loadedCheerio('.img-wrap > img').attr('src'),
|
||||
summary: loadedCheerio('.lead').text().trim(),
|
||||
};
|
||||
|
||||
novel.genres = loadedCheerio('td:contains("Genre")')
|
||||
.next()
|
||||
.find('a')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
novel.author = loadedCheerio('td:contains("Author")')
|
||||
.next()
|
||||
.text()
|
||||
.replace(/[\t\n]/g, '');
|
||||
|
||||
novel.status = loadedCheerio('td:contains("Status")')
|
||||
.next()
|
||||
.text()
|
||||
.replace(/[\t\n]/g, '');
|
||||
|
||||
const chapterJson = loadedCheerio('#__NEXT_DATA__').html() + '';
|
||||
const jsonData: NovelJson = JSON.parse(chapterJson);
|
||||
|
||||
const chapters: Plugin.ChapterItem[] =
|
||||
jsonData.props.pageProps.serie.chapters.map(
|
||||
(jsonChapter, chapterIndex) => ({
|
||||
name: jsonChapter.title,
|
||||
path:
|
||||
this.sourceLang +
|
||||
'serie-' +
|
||||
jsonData.props.pageProps.serie.serie_data.raw_id +
|
||||
'/' +
|
||||
jsonData.props.pageProps.serie.serie_data.slug +
|
||||
'/chapter-' +
|
||||
jsonChapter.order, // Assuming 'slug' is the intended path
|
||||
releaseTime: (
|
||||
jsonChapter?.created_at || jsonChapter?.updated_at
|
||||
)?.substring(0, 10),
|
||||
chapterNumber: chapterIndex + 1,
|
||||
}),
|
||||
);
|
||||
|
||||
novel.chapters = chapters;
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchApi(this.site + chapterPath).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const chapterJson = loadedCheerio('#__NEXT_DATA__').html() + '';
|
||||
const jsonData: NovelJson = JSON.parse(chapterJson);
|
||||
|
||||
const chapterContent = JSON.stringify(
|
||||
jsonData.props.pageProps.serie.chapter_data.data.body,
|
||||
);
|
||||
const parsedArray = JSON.parse(chapterContent);
|
||||
let htmlString = '';
|
||||
|
||||
for (const text of parsedArray) {
|
||||
htmlString += `<p>${text}</p>`;
|
||||
}
|
||||
|
||||
return htmlString;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const response = await fetchApi(this.site + 'api/search', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Referer: this.site + this.sourceLang,
|
||||
Origin: this.site,
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text: searchTerm }),
|
||||
});
|
||||
|
||||
const recentNovel: JsonNovel = await response.json();
|
||||
|
||||
// Parse novels from JSON
|
||||
const novels: Plugin.NovelItem[] = recentNovel.data.map((datum: Datum) => ({
|
||||
name: datum.data.title || '',
|
||||
cover: datum.data.image,
|
||||
path: this.sourceLang + 'serie-' + datum.raw_id + '/' + datum.slug || '',
|
||||
}));
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
order: {
|
||||
value: 'chapter',
|
||||
label: 'Order by',
|
||||
options: [
|
||||
{ label: 'View', value: 'view' },
|
||||
{ label: 'Name', value: 'name' },
|
||||
{ label: 'Addition Date', value: 'date' },
|
||||
{ label: 'Reader', value: 'reader' },
|
||||
{ label: 'Chapter', value: 'chapter' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
sort: {
|
||||
value: 'desc',
|
||||
label: 'Sort by',
|
||||
options: [
|
||||
{ label: 'Descending', value: 'desc' },
|
||||
{ label: 'Ascending', value: 'asc' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
storyStatus: {
|
||||
value: 'all',
|
||||
label: 'Status',
|
||||
options: [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Ongoing', value: 'ongoing' },
|
||||
{ label: 'Completed', value: 'completed' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
type NovelJson = {
|
||||
props: Props;
|
||||
page: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
pageProps: PageProps;
|
||||
__N_SSP: boolean;
|
||||
};
|
||||
|
||||
type PageProps = {
|
||||
serie: Serie;
|
||||
server_time: Date;
|
||||
};
|
||||
|
||||
type Serie = {
|
||||
serie_data: SerieData;
|
||||
chapters: Chapter[];
|
||||
recommendation: SerieData[];
|
||||
chapter_data: ChapterData;
|
||||
id: number;
|
||||
raw_id: number;
|
||||
slug: string;
|
||||
data: Data;
|
||||
is_default: boolean;
|
||||
raw_type: string;
|
||||
};
|
||||
|
||||
type Chapter = {
|
||||
serie_id: number;
|
||||
id: number;
|
||||
order: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
type ChapterData = {
|
||||
data: ChapterContent;
|
||||
};
|
||||
type ChapterContent = {
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
type SerieData = {
|
||||
serie_id?: number;
|
||||
recommendation_id?: number;
|
||||
score?: string;
|
||||
id: number;
|
||||
slug: string;
|
||||
search_text: string;
|
||||
status: number;
|
||||
data: Data;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
view: number;
|
||||
in_library: number;
|
||||
rating: number | null;
|
||||
chapter_count: number;
|
||||
power: number;
|
||||
total_rate: number;
|
||||
user_status: number;
|
||||
verified: boolean;
|
||||
from: null;
|
||||
raw_id: number;
|
||||
genres?: number[];
|
||||
};
|
||||
|
||||
type Data = {
|
||||
title: string;
|
||||
author: string;
|
||||
description: string;
|
||||
image: string;
|
||||
};
|
||||
|
||||
type JsonNovel = {
|
||||
success: boolean;
|
||||
data: Datum[];
|
||||
};
|
||||
type Datum = {
|
||||
serie: Serie;
|
||||
chapters: Chapter[];
|
||||
updated_at: Date;
|
||||
raw_id: number;
|
||||
slug: string;
|
||||
data: Data;
|
||||
};
|
||||
|
||||
export default new WTRLAB();
|
||||
@@ -0,0 +1,468 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchProto } from '@/lib/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
type StringValue = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
type BoolValue = {
|
||||
value: boolean;
|
||||
};
|
||||
|
||||
type DecimalValue = {
|
||||
units: bigint;
|
||||
nanos: number;
|
||||
};
|
||||
|
||||
type Timestamp = {
|
||||
seconds: number;
|
||||
nanos: number;
|
||||
};
|
||||
|
||||
type NovelEntry = {
|
||||
name: string;
|
||||
coverUrl: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
type RelatedChapterUserInfo = {
|
||||
isChapterUnlocked?: BoolValue;
|
||||
};
|
||||
|
||||
type ChapterItem = {
|
||||
entityId: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
number?: DecimalValue;
|
||||
content?: StringValue;
|
||||
relatedUserInfo?: RelatedChapterUserInfo;
|
||||
offset: number;
|
||||
publishedAt?: Timestamp;
|
||||
};
|
||||
|
||||
type GetChapterResponse = {
|
||||
item?: ChapterItem;
|
||||
};
|
||||
|
||||
type ChapterGroupCounts = {
|
||||
total: number;
|
||||
advance: number;
|
||||
normal: number;
|
||||
};
|
||||
|
||||
type ChapterGroupItem = {
|
||||
id: number;
|
||||
title: string;
|
||||
order: number;
|
||||
fromChapterNumber?: DecimalValue;
|
||||
toChapterNumber?: DecimalValue;
|
||||
chapterList: ChapterItem[];
|
||||
counts?: ChapterGroupCounts;
|
||||
};
|
||||
|
||||
type GetChapterListResponse = {
|
||||
items: ChapterGroupItem[];
|
||||
};
|
||||
|
||||
enum NovelItem_Status {
|
||||
Finished = 0,
|
||||
Active = 1,
|
||||
Hiatus = 2,
|
||||
All = -1,
|
||||
}
|
||||
|
||||
type NovelKarmaInfo = {
|
||||
isActive: boolean;
|
||||
isFree: boolean;
|
||||
maxFreeChapter?: DecimalValue;
|
||||
canUnlockWithVip: boolean;
|
||||
};
|
||||
|
||||
type NovelItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
status: NovelItem_Status;
|
||||
visible: boolean;
|
||||
description?: StringValue;
|
||||
synopsis?: StringValue;
|
||||
coverUrl?: StringValue;
|
||||
translatorName?: StringValue;
|
||||
authorName?: StringValue;
|
||||
karmaInfo?: NovelKarmaInfo;
|
||||
genres: string[];
|
||||
};
|
||||
|
||||
type GetNovelResponse = {
|
||||
item?: NovelItem;
|
||||
};
|
||||
|
||||
class WuxiaWorld implements Plugin.PluginBase {
|
||||
id = 'wuxiaworld';
|
||||
name = 'Wuxia World';
|
||||
icon = 'src/en/wuxiaworld/icon.png';
|
||||
site = 'https://www.wuxiaworld.com/';
|
||||
apiSite = 'https://api2.wuxiaworld.com/wuxiaworld.api.v2.';
|
||||
filters?: Filters | undefined;
|
||||
version = '0.5.1';
|
||||
|
||||
parseNovels(data: { items: NovelEntry[] }) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
data.items.map((novel: NovelEntry) => {
|
||||
const name = novel.name;
|
||||
const cover = novel.coverUrl;
|
||||
const path = `novel/${novel.slug}/`;
|
||||
|
||||
novels.push({
|
||||
name,
|
||||
cover,
|
||||
path,
|
||||
});
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(): Promise<Plugin.NovelItem[]> {
|
||||
const link = `${this.site}api/novels`;
|
||||
|
||||
const result = await fetchApi(link);
|
||||
const data = await result.json();
|
||||
|
||||
return this.parseNovels(data);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const data = await fetchProto<GetNovelResponse>(
|
||||
{
|
||||
proto: this.proto,
|
||||
requestType: 'GetNovelRequest',
|
||||
responseType: 'GetNovelResponse',
|
||||
requestData: { slug: novelPath.split('/')[1] },
|
||||
},
|
||||
this.apiSite + 'Novels/GetNovel',
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/grpc-web+proto',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: data.item?.name || 'Untitled',
|
||||
cover: data.item?.coverUrl?.value,
|
||||
summary: parseHTML(
|
||||
data.item?.description?.value + '\n\n' + data.item?.synopsis?.value,
|
||||
).text(),
|
||||
author: data.item?.authorName?.value,
|
||||
genres: data.item?.genres.join(','),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
const status = data.item?.status;
|
||||
switch (status) {
|
||||
case NovelItem_Status.Active:
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
break;
|
||||
case NovelItem_Status.Hiatus:
|
||||
novel.status = NovelStatus.OnHiatus;
|
||||
break;
|
||||
case NovelItem_Status.All:
|
||||
novel.status = NovelStatus.Unknown;
|
||||
break;
|
||||
default:
|
||||
novel.status = NovelStatus.Completed;
|
||||
}
|
||||
|
||||
// novel.summary = loadedCheerio('.relative > .absolute:first')
|
||||
// .children('span')
|
||||
// .map((i,el) => loadedCheerio(el).text().trim())
|
||||
// .toArray()
|
||||
// .join('\n');
|
||||
|
||||
// novel.genres = loadedCheerio("a.MuiLink-underlineNone")
|
||||
// .map((i,el) => loadedCheerio(el).text())
|
||||
// .toArray()
|
||||
// .join(',');
|
||||
|
||||
// novel.status = loadedCheerio("div.font-set-b10").text();
|
||||
|
||||
const list = await fetchProto<GetChapterListResponse>(
|
||||
{
|
||||
proto: this.proto,
|
||||
requestType: 'GetChapterListRequest',
|
||||
responseType: 'GetChapterListResponse',
|
||||
requestData: { novelId: data.item?.id },
|
||||
},
|
||||
this.apiSite + 'Chapters/GetChapterList',
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/grpc-web+proto',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const freeChapter =
|
||||
Number(data.item?.karmaInfo?.maxFreeChapter?.units) +
|
||||
(data.item?.karmaInfo?.maxFreeChapter?.nanos || 0) / 1000000000 || 50;
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = list.items.flatMap(
|
||||
(ChapterGroupItem: ChapterGroupItem) =>
|
||||
ChapterGroupItem.chapterList.map((chapterItem: ChapterItem) => ({
|
||||
page: ChapterGroupItem.title,
|
||||
name:
|
||||
chapterItem.name +
|
||||
(chapterItem.relatedUserInfo?.isChapterUnlocked?.value === false ||
|
||||
(!chapterItem.relatedUserInfo &&
|
||||
Number(chapterItem.number?.units) +
|
||||
(chapterItem.number?.nanos || 0) / 1000000000 >
|
||||
freeChapter)
|
||||
? ' 🔒'
|
||||
: ''),
|
||||
path: novelPath + chapterItem.slug,
|
||||
chapterNumber: chapterItem.offset,
|
||||
releaseTime: new Date(
|
||||
(chapterItem.publishedAt?.seconds || 0) * 1000 +
|
||||
(chapterItem.publishedAt?.nanos || 0) / 1000000,
|
||||
).toISOString(),
|
||||
})),
|
||||
);
|
||||
|
||||
novel.chapters = chapter;
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const paths = chapterPath.split('/');
|
||||
const data = await fetchProto<GetChapterResponse>(
|
||||
{
|
||||
proto: this.proto,
|
||||
requestType: 'GetChapterRequest',
|
||||
responseType: 'GetChapterResponse',
|
||||
requestData: {
|
||||
chapterProperty: {
|
||||
slugs: {
|
||||
novelSlug: paths[1],
|
||||
chapterSlug: paths[2],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
this.apiSite + 'Chapters/GetChapter',
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/grpc-web+proto',
|
||||
},
|
||||
},
|
||||
);
|
||||
// loadedCheerio(".chapter-nav").remove();
|
||||
// loadedCheerio("#chapter-content > script").remove();
|
||||
// const chapterText = loadedCheerio("#chapter-content").html() || '';
|
||||
|
||||
const chapterText = data.item?.content?.value || '';
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const url =
|
||||
this.site + 'api/novels/search?query=' + encodeURIComponent(searchTerm);
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const data = await result.json();
|
||||
|
||||
return this.parseNovels(data);
|
||||
}
|
||||
|
||||
proto = `
|
||||
syntax = "proto3";
|
||||
option optimize_for = CODE_SIZE;
|
||||
package wuxiaworld.api.v2;
|
||||
|
||||
import public "google/protobuf/wrappers.proto";
|
||||
import public "google/protobuf/timestamp.proto";
|
||||
|
||||
message StringValue {
|
||||
// The string value.
|
||||
string value = 1;
|
||||
}
|
||||
|
||||
message BoolValue {
|
||||
// The bool value.
|
||||
bool value = 1;
|
||||
}
|
||||
|
||||
message Int32Value {
|
||||
// The int32 value.
|
||||
int32 value = 1;
|
||||
}
|
||||
|
||||
message DecimalValue {
|
||||
// Whole units part of the amount
|
||||
int64 units = 1;
|
||||
// Nano units of the amount (10^-9)
|
||||
// Must be the same sign as units
|
||||
sfixed32 nanos = 2;
|
||||
}
|
||||
|
||||
message Timestamp {
|
||||
int64 seconds = 1;
|
||||
int32 nanos = 2;
|
||||
}
|
||||
|
||||
message RelatedChapterUserInfo {
|
||||
optional BoolValue isChapterUnlocked = 1;
|
||||
optional BoolValue isNovelUnlocked = 2;
|
||||
optional BoolValue isChapterFavorite = 3;
|
||||
optional BoolValue isNovelOwned = 4;
|
||||
optional BoolValue isChapterOwned = 5;
|
||||
}
|
||||
|
||||
message ChapterNovelInfo {
|
||||
int32 id = 1;
|
||||
string name = 2;
|
||||
optional StringValue coverUrl = 3;
|
||||
string slug = 4;
|
||||
}
|
||||
|
||||
message ChapterParagraph {
|
||||
string id = 1;
|
||||
int32 chapterId = 2;
|
||||
int32 totalComments = 3;
|
||||
optional StringValue content = 4;
|
||||
}
|
||||
|
||||
message ChapterItem {
|
||||
int32 entityId = 1;
|
||||
string name = 2;
|
||||
string slug = 3;
|
||||
optional DecimalValue number = 4;
|
||||
optional StringValue content = 5;
|
||||
int32 novelId = 6;
|
||||
bool visible = 7;
|
||||
bool isTeaser = 8;
|
||||
optional Timestamp whenToPublish = 9;
|
||||
bool spoilerTitle = 10;
|
||||
bool allowComments = 11;
|
||||
optional ChapterNovelInfo novelInfo = 14;
|
||||
optional RelatedChapterUserInfo relatedUserInfo = 16;
|
||||
int32 offset = 17;
|
||||
optional Timestamp publishedAt = 18;
|
||||
optional StringValue translatorThoughts = 19;
|
||||
repeated ChapterParagraph paragraphs = 21;
|
||||
}
|
||||
|
||||
message ChapterGroupCounts {
|
||||
int32 total = 1;
|
||||
int32 advance = 2;
|
||||
int32 normal = 3;
|
||||
}
|
||||
|
||||
message ChapterGroupItem {
|
||||
int32 id = 1;
|
||||
string title = 2;
|
||||
int32 order = 3;
|
||||
optional DecimalValue fromChapterNumber = 4;
|
||||
optional DecimalValue toChapterNumber = 5;
|
||||
repeated ChapterItem chapterList = 6;
|
||||
optional ChapterGroupCounts counts = 7;
|
||||
}
|
||||
|
||||
message GetChapterListRequest {
|
||||
int32 novelId = 1;
|
||||
message BaseChapterInfo {
|
||||
oneof chapterInfo {
|
||||
int32 chapterId = 1;
|
||||
string slug = 2;
|
||||
int32 offset = 3;
|
||||
}
|
||||
}
|
||||
message FilterChapters {
|
||||
optional Int32Value chapterGroupId = 1;
|
||||
optional BoolValue isAdvanceChapter = 2;
|
||||
optional BaseChapterInfo baseChapter = 3;
|
||||
}
|
||||
optional FilterChapters filter = 2;
|
||||
optional Int32Value count = 3;
|
||||
}
|
||||
|
||||
message GetChapterListResponse {
|
||||
repeated ChapterGroupItem items = 1;
|
||||
optional ChapterNovelInfo novelInfo = 2;
|
||||
}
|
||||
|
||||
message GetChapterByProperty {
|
||||
message ByNovelAndChapterSlug {
|
||||
string novelSlug = 1;
|
||||
string chapterSlug = 2;
|
||||
}
|
||||
oneof byProperty {
|
||||
int32 chapterId = 1;
|
||||
ByNovelAndChapterSlug slugs = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message GetChapterRequest {
|
||||
optional GetChapterByProperty chapterProperty = 1;
|
||||
}
|
||||
|
||||
message GetChapterResponse {
|
||||
optional ChapterItem item = 1;
|
||||
}
|
||||
|
||||
message NovelKarmaInfo {
|
||||
bool isActive = 1;
|
||||
bool isFree = 2;
|
||||
optional DecimalValue maxFreeChapter = 3;
|
||||
bool canUnlockWithVip = 4;
|
||||
}
|
||||
|
||||
message NovelItem {
|
||||
int32 id = 1;
|
||||
string name = 2;
|
||||
string slug = 3;
|
||||
enum Status {
|
||||
Finished = 0;
|
||||
Active = 1;
|
||||
Hiatus = 2;
|
||||
All = -1;
|
||||
}
|
||||
Status status = 4;
|
||||
bool visible = 7;
|
||||
optional StringValue description = 8;
|
||||
optional StringValue synopsis = 9;
|
||||
optional StringValue coverUrl = 10;
|
||||
optional StringValue translatorName = 11;
|
||||
optional StringValue authorName = 13;
|
||||
optional NovelKarmaInfo karmaInfo = 14;
|
||||
repeated string genres = 16;
|
||||
}
|
||||
|
||||
message GetNovelRequest {
|
||||
oneof selector {
|
||||
int32 id = 1;
|
||||
string slug = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message GetNovelResponse {
|
||||
optional NovelItem item = 1;
|
||||
}
|
||||
|
||||
service Chapters {
|
||||
rpc GetChapterList(GetChapterListRequest) returns (GetChapterListResponse);
|
||||
rpc GetChapter(GetChapterRequest) returns (GetChapterResponse);
|
||||
}
|
||||
|
||||
service Novels {
|
||||
rpc GetNovel(GetNovelRequest) returns (GetNovelResponse);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export default new WuxiaWorld();
|
||||
@@ -0,0 +1,339 @@
|
||||
import { CheerioAPI, load } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import dayjs from 'dayjs';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class ChireadsPlugin implements Plugin.PluginBase {
|
||||
id = 'chireads';
|
||||
name = 'Chireads';
|
||||
icon = 'src/fr/chireads/icon.png';
|
||||
site = 'https://chireads.com';
|
||||
version = '1.0.2';
|
||||
|
||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||
const r = await fetchApi(url, {
|
||||
headers: { 'Accept-Encoding': 'deflate' },
|
||||
});
|
||||
const body = await r.text();
|
||||
const $ = load(body);
|
||||
return $;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ filters, showLatestNovels }: Plugin.PopularNovelsOptions,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site;
|
||||
let tag = 'all';
|
||||
if (showLatestNovels) url += '/category/translatedtales/page/' + pageNo;
|
||||
else {
|
||||
if (
|
||||
filters &&
|
||||
typeof filters.tag.value === 'string' &&
|
||||
filters.tag.value !== 'all'
|
||||
)
|
||||
tag = filters.tag.value;
|
||||
if (tag !== 'all') url += '/tag/' + tag + '/page/' + pageNo;
|
||||
else if (pageNo > 1) return [];
|
||||
}
|
||||
let $ = await this.getCheerio(url);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
let novel: Plugin.NovelItem;
|
||||
|
||||
if (showLatestNovels || tag !== 'all') {
|
||||
let loop = 1;
|
||||
if (showLatestNovels) loop = 2;
|
||||
for (let i = 0; i < loop; i++) {
|
||||
if (i === 1)
|
||||
$ = await this.getCheerio(
|
||||
this.site + '/category/original/page/' + pageNo,
|
||||
);
|
||||
let romans = $('.romans-content li');
|
||||
if (!romans.length) romans = $('#content li');
|
||||
romans.each((i, elem) => {
|
||||
const novelName = $(elem)
|
||||
.contents()
|
||||
.find('div')
|
||||
.first()
|
||||
.text()
|
||||
.trim();
|
||||
const novelCover = $(elem)
|
||||
.find('div')
|
||||
.first()
|
||||
.find('img')
|
||||
.attr('src');
|
||||
const novelUrl = $(elem).find('div').first().find('a').attr('href');
|
||||
|
||||
if (novelUrl) {
|
||||
novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const populaire = $(':contains("Populaire")')
|
||||
.last()
|
||||
.parent()
|
||||
.next()
|
||||
.find('li > div');
|
||||
if (populaire.length === 12) {
|
||||
// pc
|
||||
let novelCover: string | undefined;
|
||||
let novelName: string | undefined;
|
||||
let novelUrl: string | undefined;
|
||||
populaire.each((i, elem) => {
|
||||
if (i % 2 === 0) novelCover = $(elem).find('img').attr('src');
|
||||
else {
|
||||
novelName = $(elem).text().trim();
|
||||
novelUrl = $(elem).find('a').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
novel = {
|
||||
name: novelName,
|
||||
cover: novelCover || defaultCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
} // mobile
|
||||
else {
|
||||
const imgs = populaire.find('div.popular-list-img img');
|
||||
const txts = populaire.find('div.popular-list-name');
|
||||
|
||||
txts.each((i, elem) => {
|
||||
const novelName = $(elem).text().trim();
|
||||
const novelCover = $(imgs[i]).attr('src');
|
||||
const novelUrl = $(elem).find('a').attr('href');
|
||||
|
||||
if (novelUrl) {
|
||||
novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novel: Plugin.SourceNovel = { path: novelPath, name: 'Sans titre' };
|
||||
|
||||
const $ = await this.getCheerio(this.site + novelPath);
|
||||
|
||||
novel.name =
|
||||
$('.inform-product-txt').first().text().trim() ||
|
||||
$('.inform-title').text().trim();
|
||||
novel.cover =
|
||||
$('.inform-product img').attr('src') ||
|
||||
$('.inform-product-img img').attr('src') ||
|
||||
defaultCover;
|
||||
novel.summary =
|
||||
$('.inform-inform-txt').text().trim() ||
|
||||
$('.inform-intr-txt').text().trim();
|
||||
|
||||
const infos =
|
||||
$('div.inform-product-txt > div.inform-intr-col').text().trim() ||
|
||||
$('div.inform-inform-data > h6').text().trim();
|
||||
if (infos.includes('Auteur : '))
|
||||
novel.author = infos
|
||||
.substring(
|
||||
infos.indexOf('Auteur : ') + 9,
|
||||
infos.indexOf('Statut de Parution : '),
|
||||
)
|
||||
.trim();
|
||||
else if (infos.includes('Fantrad : '))
|
||||
novel.author = infos
|
||||
.substring(
|
||||
infos.indexOf('Fantrad : ') + 10,
|
||||
infos.indexOf('Statut de Parution : '),
|
||||
)
|
||||
.trim();
|
||||
else novel.author = 'Inconnu';
|
||||
switch (
|
||||
infos.substring(infos.indexOf('Statut de Parution : ') + 21).toLowerCase()
|
||||
) {
|
||||
case 'en pause':
|
||||
novel.status = NovelStatus.OnHiatus;
|
||||
break;
|
||||
case 'complet':
|
||||
novel.status = NovelStatus.Completed;
|
||||
break;
|
||||
default:
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
break;
|
||||
}
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
let chapterList = $('.chapitre-table a');
|
||||
if (!chapterList.length) {
|
||||
$('div.inform-annexe-list').first().remove();
|
||||
chapterList = $('.inform-annexe-list').find('a');
|
||||
}
|
||||
chapterList.each((i, elem) => {
|
||||
const chapterName = $(elem).text().trim();
|
||||
const chapterUrl = $(elem).attr('href');
|
||||
const releaseDate = dayjs(
|
||||
chapterUrl?.substring(chapterUrl.length - 11, chapterUrl.length - 1),
|
||||
).format('DD MMMM YYYY');
|
||||
|
||||
if (chapterUrl) {
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
releaseTime: releaseDate,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
novel.chapters = chapters;
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterUrl: string): Promise<string> {
|
||||
const $ = await this.getCheerio(this.site + chapterUrl);
|
||||
|
||||
const chapterText = $('#content').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo !== 1) return [];
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
|
||||
let i = 1;
|
||||
let finised = false;
|
||||
while (!finised) {
|
||||
await this.popularNovels(i, {
|
||||
showLatestNovels: true,
|
||||
filters: undefined,
|
||||
}).then(res => {
|
||||
if (res.length === 0) finised = true;
|
||||
novels.push(...res);
|
||||
});
|
||||
i++;
|
||||
}
|
||||
|
||||
novels = novels.filter(novel =>
|
||||
novel.name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.includes(
|
||||
searchTerm
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, ''),
|
||||
),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
tag: {
|
||||
type: FilterTypes.Picker,
|
||||
label: 'Tag',
|
||||
value: 'all',
|
||||
options: [
|
||||
{ label: 'Tous', value: 'all' },
|
||||
{ label: 'Arts martiaux', value: 'arts-martiaux' },
|
||||
{ label: 'De faible à fort', value: 'de-faible-a-fort' },
|
||||
{ label: 'Adapté en manhua', value: 'adapte-en-manhua' },
|
||||
{ label: 'Cultivation', value: 'cultivation' },
|
||||
{ label: 'Action', value: 'action' },
|
||||
{ label: 'Aventure', value: 'aventure' },
|
||||
{ label: 'Monstres', value: 'monstres' },
|
||||
{ label: 'Xuanhuan', value: 'xuanhuan' },
|
||||
{ label: 'Fantastique', value: 'fantastique' },
|
||||
{ label: 'Adapté en Animé', value: 'adapte-en-anime' },
|
||||
{ label: 'Alchimie', value: 'alchimie' },
|
||||
{ label: 'Éléments de jeux', value: 'elements-de-jeux' },
|
||||
{ label: 'Calme Protagoniste', value: 'calme-protagoniste' },
|
||||
{
|
||||
label: 'Protagoniste intelligent',
|
||||
value: 'protagoniste-intelligent',
|
||||
},
|
||||
{ label: 'Polygamie', value: 'polygamie' },
|
||||
{ label: 'Belle femelle Lea', value: 'belle-femelle-lea' },
|
||||
{ label: 'Personnages arrogants', value: 'personnages-arrogants' },
|
||||
{ label: 'Système de niveau', value: 'systeme-de-niveau' },
|
||||
{ label: 'Cheat', value: 'cheat' },
|
||||
{ label: 'Protagoniste génie', value: 'protagoniste-genie' },
|
||||
{ label: 'Comédie', value: 'comedie' },
|
||||
{ label: 'Gamer', value: 'gamer' },
|
||||
{ label: 'Mariage', value: 'mariage' },
|
||||
{ label: 'seeking Protag', value: 'seeking-protag' },
|
||||
{ label: 'Romance précoce', value: 'romance-precoce' },
|
||||
{ label: 'Croissance accélérée', value: 'croissance-acceleree' },
|
||||
{ label: 'Artefacts', value: 'artefacts' },
|
||||
{
|
||||
label: 'Intelligence artificielle',
|
||||
value: 'intelligence-artificielle',
|
||||
},
|
||||
{ label: 'Mariage arrangé', value: 'mariage-arrange' },
|
||||
{ label: 'Mature', value: 'mature' },
|
||||
{ label: 'Adulte', value: 'adulte' },
|
||||
{
|
||||
label: 'Administrateur de système',
|
||||
value: 'administrateur-de-systeme',
|
||||
},
|
||||
{ label: 'Beau protagoniste', value: 'beau-protagoniste' },
|
||||
{
|
||||
label: 'Protagoniste charismatique',
|
||||
value: 'protagoniste-charismatique',
|
||||
},
|
||||
{ label: 'Protagoniste masculin', value: 'protagoniste-masculin' },
|
||||
{ label: 'Démons', value: 'demons' },
|
||||
{ label: 'Reincarnation', value: 'reincarnation' },
|
||||
{ label: 'Académie', value: 'academie' },
|
||||
{
|
||||
label: 'Cacher les vraies capacités',
|
||||
value: 'cacher-les-vraies-capacites',
|
||||
},
|
||||
{
|
||||
label: 'Protagoniste surpuissant',
|
||||
value: 'protagoniste-surpuissant',
|
||||
},
|
||||
{ label: 'Joueur', value: 'joueur' },
|
||||
{
|
||||
label: 'Protagoniste fort dès le départ',
|
||||
value: 'protagoniste-fort-des-le-depart',
|
||||
},
|
||||
{ label: 'Immortels', value: 'immortels' },
|
||||
{ label: 'Cultivation rapide', value: 'cultivation-rapide' },
|
||||
{ label: 'Harem', value: 'harem' },
|
||||
{ label: 'Assasins', value: 'assasins' },
|
||||
{ label: 'De pauvre à riche', value: 'de-pauvre-a-riche' },
|
||||
{
|
||||
label: 'Système de classement de jeux',
|
||||
value: 'systeme-de-classement-de-jeux',
|
||||
},
|
||||
{ label: 'Capacités spéciales', value: 'capacites-speciales' },
|
||||
{ label: 'Vengeance', value: 'vengeance' },
|
||||
],
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new ChireadsPlugin();
|
||||
@@ -0,0 +1,166 @@
|
||||
import { CheerioAPI, load } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class HarkenEliwoodPlugin implements Plugin.PluginBase {
|
||||
id = 'harkeneliwood';
|
||||
name = 'HarkenEliwood';
|
||||
icon = 'src/fr/harkeneliwood/icon.png';
|
||||
site = 'https://harkeneliwood.wordpress.com';
|
||||
version = '1.0.0';
|
||||
|
||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||
const r = await fetchApi(url, {
|
||||
headers: { 'Accept-Encoding': 'deflate' },
|
||||
});
|
||||
const body = await r.text();
|
||||
const $ = load(body);
|
||||
return $;
|
||||
}
|
||||
|
||||
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo > 1) return [];
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
let novel: Plugin.NovelItem;
|
||||
const url = this.site;
|
||||
const $ = await this.getCheerio(url + '/projets/');
|
||||
$('#content .entry-content [href]')
|
||||
// We don't collect items for Facebook and Twitter.
|
||||
.not('[rel="nofollow noopener noreferrer"]')
|
||||
.each((i, elem) => {
|
||||
const novelName = $(elem).text().trim();
|
||||
const novelUrl = $(elem).attr('href');
|
||||
if (novelUrl && novelName) {
|
||||
novel = {
|
||||
name: novelName,
|
||||
cover: defaultCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: 'Sans titre',
|
||||
};
|
||||
|
||||
const $ = await this.getCheerio(this.site + novelPath);
|
||||
novel.name = $('#content h1.entry-title').text().trim();
|
||||
novel.cover =
|
||||
$('#content .entry-content p img').first().attr('src') || defaultCover;
|
||||
novel.summary = this.getSummary($('#content .entry-content').text());
|
||||
novel.author = this.getAuthor($('#content .entry-content').text());
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
$('#content .entry-content p a').each((i, elem) => {
|
||||
const chapterName = $(elem).text().trim();
|
||||
const chapterUrl = $(elem).attr('href');
|
||||
// Check if the chapter URL exists and contains the site name.
|
||||
if (chapterUrl && chapterUrl.includes(this.site) && chapterName) {
|
||||
const releaseDate = dayjs(
|
||||
chapterUrl?.substring(this.site.length + 1, this.site.length + 11),
|
||||
).format('DD MMMM YYYY');
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
releaseTime: releaseDate,
|
||||
});
|
||||
}
|
||||
});
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
getSummary(text: string) {
|
||||
let resume = '';
|
||||
const regexResume1 = /Synopsis :([\s\S]*)Traduction anglaise/i;
|
||||
const regexResume2 = /Synopsis :([\s\S]*)Raw :/i;
|
||||
const regexResume3 = /Synopsis 1 :([\s\S]*)Synopsis 2 :([\s\S]*)Raw :/i;
|
||||
const regexResume4 = /Synopsis :([\s\S]*)Prélude/i;
|
||||
const regexResume5 = /Synospis :([\s\S]*)Original /i;
|
||||
const regexResume6 = /([\s\S]*)Raw :/i;
|
||||
|
||||
const match1: RegExpExecArray | null = regexResume1.exec(text);
|
||||
const match2: RegExpExecArray | null = regexResume2.exec(text);
|
||||
const match3: RegExpExecArray | null = regexResume3.exec(text);
|
||||
const match4: RegExpExecArray | null = regexResume4.exec(text);
|
||||
const match5: RegExpExecArray | null = regexResume5.exec(text);
|
||||
|
||||
if (match1 !== null) {
|
||||
resume = match1[1];
|
||||
} else if (match2 !== null) {
|
||||
resume = match2[1];
|
||||
} else if (match3 !== null) {
|
||||
resume = match3[1] + match3[2];
|
||||
} else if (match4 !== null) {
|
||||
resume = match4[1];
|
||||
} else if (match5 !== null) {
|
||||
resume = match5[1];
|
||||
} else {
|
||||
resume = text;
|
||||
}
|
||||
|
||||
if (regexResume6.test(resume)) {
|
||||
const match6: RegExpExecArray | null = regexResume6.exec(resume);
|
||||
if (match6 !== null) {
|
||||
resume = match6[1];
|
||||
}
|
||||
}
|
||||
|
||||
return resume.trim();
|
||||
}
|
||||
|
||||
getAuthor(text: string) {
|
||||
const regexAuteur = /Auteur\s*:\s*(.*?)\s*(?:\r?\n|$)/i;
|
||||
const match = regexAuteur.exec(text);
|
||||
|
||||
if (match !== null && match[1].trim() !== '') {
|
||||
return match[1].trim();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const $ = await this.getCheerio(this.site + chapterPath);
|
||||
const title = $('h1.entry-title');
|
||||
const chapter = $('div.entry-content');
|
||||
return (title.html() || '') + (chapter.html() || '');
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo !== 1) return [];
|
||||
|
||||
const popularNovels = this.popularNovels(1);
|
||||
|
||||
const novels = (await popularNovels).filter(novel =>
|
||||
novel.name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.includes(
|
||||
searchTerm
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim(),
|
||||
),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new HarkenEliwoodPlugin();
|
||||
@@ -0,0 +1,240 @@
|
||||
import { CheerioAPI, load } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class KissWoodPlugin implements Plugin.PluginBase {
|
||||
id = 'kisswood';
|
||||
name = 'KissWood';
|
||||
icon = 'src/fr/kisswood/icon.png';
|
||||
site = 'https://kisswood.eu';
|
||||
version = '1.0.0';
|
||||
|
||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||
const r = await fetchApi(url);
|
||||
const body = await r.text();
|
||||
const $ = load(body);
|
||||
return $;
|
||||
}
|
||||
|
||||
async getNovelsCovers(
|
||||
novels: Plugin.NovelItem[],
|
||||
listUrlCover: string[],
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
await Promise.all(
|
||||
novels.map(async (novel, index) => {
|
||||
const urlCover = listUrlCover[index];
|
||||
if (urlCover) {
|
||||
novel.cover = this.findCoverImage(await this.getCheerio(urlCover));
|
||||
}
|
||||
}),
|
||||
);
|
||||
return novels;
|
||||
}
|
||||
|
||||
regexAuthors = [/Auteur :([^\n]*)/, /Auteur\u00A0:([^\n]*)/];
|
||||
|
||||
async getNovelInfo(
|
||||
novel: Plugin.SourceNovel,
|
||||
url: string,
|
||||
): Promise<Plugin.SourceNovel> {
|
||||
const $ = await this.getCheerio(url);
|
||||
|
||||
const textArray: string[] = $('.entry-content p')
|
||||
.map((_, element) => $(element).text().trim())
|
||||
.get()
|
||||
.join('\n')
|
||||
.split('\n');
|
||||
|
||||
const index = textArray.findIndex(element =>
|
||||
[
|
||||
'Traducteur Anglais- Français',
|
||||
'Titre en français',
|
||||
'———',
|
||||
'Titre :',
|
||||
'Lien vers le premier chapitre',
|
||||
'____________',
|
||||
'Auteur : ',
|
||||
].some(marker => element.includes(marker)),
|
||||
);
|
||||
|
||||
novel.summary = (index !== -1 ? textArray.slice(0, index) : textArray)
|
||||
.join('\n')
|
||||
.replace('Synopsis :', '');
|
||||
novel.author = this.extractInfo(textArray.join('\n'), this.regexAuthors);
|
||||
novel.cover = this.findCoverImage($);
|
||||
return novel;
|
||||
}
|
||||
|
||||
findCoverImage($: CheerioAPI): string {
|
||||
return (
|
||||
$('div p img').first().attr('src') ||
|
||||
$('figure a img').first().attr('src') ||
|
||||
$('figure img').first().attr('src') ||
|
||||
defaultCover
|
||||
);
|
||||
}
|
||||
|
||||
extractInfo(text: string, regexes: RegExp[]): string {
|
||||
for (const regex of regexes) {
|
||||
const match = regex.exec(text);
|
||||
if (match !== null) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo > 1) return [];
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
const $ = await this.getCheerio(this.site);
|
||||
const listUrlCover: string[] = [];
|
||||
$('nav div div ul li ul li').each((i, elem) => {
|
||||
if ($(elem).text().trim() === 'Sommaire') {
|
||||
const novelName = $(elem)
|
||||
.closest('ul')
|
||||
.siblings('a')
|
||||
.first()
|
||||
.text()
|
||||
.trim();
|
||||
const novelUrl = $(elem).find('a').attr('href');
|
||||
|
||||
if (novelUrl && novelName) {
|
||||
const urlCover = $(elem).parent().find('a').attr('href');
|
||||
if (urlCover) {
|
||||
listUrlCover.push(urlCover);
|
||||
} else {
|
||||
listUrlCover.push('');
|
||||
}
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
cover: defaultCover,
|
||||
};
|
||||
novels.push(novel);
|
||||
}
|
||||
}
|
||||
});
|
||||
return await this.getNovelsCovers(novels, listUrlCover);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
let novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: 'Sans titre',
|
||||
status: NovelStatus.Ongoing,
|
||||
};
|
||||
|
||||
const $ = await this.getCheerio(this.site + novelPath);
|
||||
let novelUrl = null;
|
||||
|
||||
$('nav div div ul li ul li').each((i, elem) => {
|
||||
if ($(elem).find('a').attr('href') === this.site + novelPath) {
|
||||
novelUrl = $(elem).parent().find('a').first().attr('href');
|
||||
novel.name = $(elem).closest('ul').siblings('a').first().text().trim();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
if (novelUrl) {
|
||||
novel = await this.getNovelInfo(novel, novelUrl);
|
||||
}
|
||||
|
||||
const chapterSelectors = [
|
||||
'.entry-content ul li a',
|
||||
'.entry-content ul li ul li a',
|
||||
'.entry-content p a',
|
||||
'.entry-content li a',
|
||||
'.entry-content blockquote a',
|
||||
].join(', ');
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
$(chapterSelectors).each((i, elem) => {
|
||||
const chapterName = $(elem).text().trim();
|
||||
const chapterUrl = $(elem).attr('href')?.replace('http://', 'https://');
|
||||
if (
|
||||
chapterUrl &&
|
||||
chapterName &&
|
||||
chapterUrl.includes(this.site) &&
|
||||
// We remove the unnecessary links to Facebook, X, and the homepage from the chapters.
|
||||
!chapterUrl.includes('share=facebook') &&
|
||||
!chapterUrl.includes('share=x') &&
|
||||
!chapterUrl.includes('/category/traductions/') &&
|
||||
!chapterUrl.includes('/category/tour-des-mondes/') &&
|
||||
// Removal of duplicates
|
||||
!chapters.some(chapter => this.site + chapter.path === chapterUrl)
|
||||
) {
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
}
|
||||
});
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const $ = await this.getCheerio(this.site + chapterPath);
|
||||
|
||||
const elements: string[] = $('.entry-content')
|
||||
.contents()
|
||||
.map((_, el) => $.html(el))
|
||||
.get();
|
||||
|
||||
let hrIndexes: number[] = elements
|
||||
.map((elem, index) => (elem.includes('<hr>') ? index : -1))
|
||||
.filter(index => index !== -1);
|
||||
|
||||
if (hrIndexes.length === 0) {
|
||||
hrIndexes = [
|
||||
0,
|
||||
elements.findIndex(
|
||||
element =>
|
||||
element.includes('https://fr.tipeee.com/kisswood/') ||
|
||||
element.includes('>Sommaire</a>') ||
|
||||
element.includes('>Chapitre Suivant</a>') ||
|
||||
element.includes('———————————————————————————-') ||
|
||||
element.includes('share=facebook'),
|
||||
),
|
||||
];
|
||||
} else if (hrIndexes.length === 1) {
|
||||
hrIndexes.unshift(0);
|
||||
} else {
|
||||
hrIndexes[0] += 1;
|
||||
}
|
||||
return elements.slice(hrIndexes[0], hrIndexes[1]).join('\n');
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo !== 1) return [];
|
||||
|
||||
const popularNovels = this.popularNovels(1);
|
||||
|
||||
const novels = (await popularNovels).filter(novel =>
|
||||
novel.name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.includes(
|
||||
searchTerm
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim(),
|
||||
),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new KissWoodPlugin();
|
||||
@@ -0,0 +1,316 @@
|
||||
import { load, CheerioAPI } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
|
||||
class NovelDeGlacePlugin implements Plugin.PluginBase {
|
||||
id = 'noveldeglace';
|
||||
name = 'NovelDeGlace';
|
||||
icon = 'src/fr/noveldeglace/icon.png';
|
||||
site = 'https://noveldeglace.com/';
|
||||
version = '1.0.4';
|
||||
|
||||
async getCheerio(url: string): Promise<CheerioAPI | undefined> {
|
||||
const r = await fetchApi(url, {
|
||||
headers: { 'Accept-Encoding': 'deflate' },
|
||||
});
|
||||
if (!r.ok) return undefined;
|
||||
const body = await r.text();
|
||||
const loadedCheerio = load(body);
|
||||
return loadedCheerio;
|
||||
}
|
||||
|
||||
parseNovels(
|
||||
$: CheerioAPI,
|
||||
showLatestNovels: boolean | undefined,
|
||||
): Plugin.NovelItem[] {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
$('article').each((i, el) => {
|
||||
const cheerio = $(el);
|
||||
const novelName = cheerio.find('h2').text().trim();
|
||||
const novelCover = cheerio.find('img').attr('src');
|
||||
let novelUrl: string | undefined;
|
||||
if (showLatestNovels)
|
||||
novelUrl = cheerio.find('span.Roman > a').attr('href');
|
||||
else novelUrl = cheerio.find('h2 > a').attr('href');
|
||||
|
||||
if (novelUrl) {
|
||||
const novel: Plugin.NovelItem = {
|
||||
name: novelName,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
cover: novelCover,
|
||||
};
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
filters,
|
||||
showLatestNovels,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site;
|
||||
if (showLatestNovels) url += 'chapitre';
|
||||
else {
|
||||
let cat_gen = 'all';
|
||||
if (filters && typeof filters.categorie_genre.value == 'string')
|
||||
cat_gen = filters.categorie_genre.value;
|
||||
if (
|
||||
cat_gen != 'all' &&
|
||||
cat_gen != 'categorie_roman' &&
|
||||
cat_gen != 'genre'
|
||||
) {
|
||||
if (cat_gen[0] == 'c') url += 'categorie_roman/' + cat_gen.substring(2);
|
||||
else if (cat_gen[0] == 'g') url += 'genre/' + cat_gen.substring(2);
|
||||
} else if (pageNo > 1)
|
||||
return []; // when asking for all novels, there is only 1 page
|
||||
else url += 'roman';
|
||||
}
|
||||
url += '/page/' + pageNo;
|
||||
const $ = await this.getCheerio(url);
|
||||
if (!$) return [];
|
||||
return this.parseNovels($, showLatestNovels);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const $ = await this.getCheerio(this.site + novelPath);
|
||||
if (!$) throw new Error('Failed to load page (open in web view)');
|
||||
|
||||
const novel: Plugin.SourceNovel = { path: novelPath, name: 'Untitled' };
|
||||
|
||||
novel.name =
|
||||
(
|
||||
$('div.entry-content > div > strong')[0].nextSibling as string | null
|
||||
)?.nodeValue?.trim() || 'Untitled';
|
||||
|
||||
novel.cover = $('.su-row > div > div > img').attr('src') || defaultCover;
|
||||
|
||||
const novelInfos = $('div[data-title=Tomes] >').toArray();
|
||||
novelInfos.pop();
|
||||
novelInfos.shift();
|
||||
novel.summary =
|
||||
$('div[data-title=Synopsis]').text().trim() +
|
||||
'\n\n' +
|
||||
novelInfos
|
||||
.map(el => $(el).text())
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
novel.author = $("strong:contains('Auteur :')")
|
||||
.parent()
|
||||
.text()
|
||||
.replace('Auteur : ', '')
|
||||
.trim();
|
||||
|
||||
novel.artist = $("strong:contains('Illustrateur :')")
|
||||
.parent()
|
||||
.text()
|
||||
.replace('Illustrateur :', '')
|
||||
.trim();
|
||||
|
||||
const categorie = $('.categorie').text().replace('Catégorie :', '').trim();
|
||||
const genres = $('.genre')
|
||||
.text()
|
||||
.replace('Genre :', '')
|
||||
.replace(/, /g, ',')
|
||||
.trim();
|
||||
if (categorie && categorie != 'Autre') novel.genres = categorie;
|
||||
if (genres)
|
||||
novel.genres = novel.genres ? novel.genres + ',' + genres : genres;
|
||||
|
||||
const status = $("strong:contains('Statut :')").parent().attr('class');
|
||||
switch (status) {
|
||||
case 'type etat0':
|
||||
case 'type etat1':
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
break;
|
||||
case 'type etat4':
|
||||
novel.status = NovelStatus.OnHiatus;
|
||||
break;
|
||||
case 'type etat5':
|
||||
novel.status = NovelStatus.Completed;
|
||||
break;
|
||||
case 'type etat6':
|
||||
novel.status = NovelStatus.Cancelled;
|
||||
break;
|
||||
default:
|
||||
novel.status = NovelStatus.Unknown;
|
||||
break;
|
||||
}
|
||||
|
||||
const novelChapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
const volumes = $('div[data-title=Tomes] > div').last().contents();
|
||||
const hasMultipleVolumes = volumes.length > 1;
|
||||
|
||||
let chapterName = '';
|
||||
const site = this.site;
|
||||
volumes.each((volumeIndex: number, el) => {
|
||||
if (hasMultipleVolumes) chapterName = 'T.' + (volumeIndex + 1) + ' ';
|
||||
$(el)
|
||||
.find('.chpt')
|
||||
.each((chapterIndex: number, el) => {
|
||||
const cheerio = $(el);
|
||||
const newChapterName =
|
||||
chapterName + cheerio.find('a').first().text().trim() || '';
|
||||
if (!cheerio.find('i.fa').length) {
|
||||
// no parts
|
||||
const dateHtml =
|
||||
cheerio.html()?.substring(cheerio.html()?.indexOf('</a>') || 0) ||
|
||||
'';
|
||||
const releaseDate =
|
||||
dateHtml?.substring(
|
||||
dateHtml.indexOf('(') + 1,
|
||||
dateHtml.indexOf(')'),
|
||||
) || undefined;
|
||||
const chapterUrl = cheerio.find('a').attr('href');
|
||||
if (chapterUrl) {
|
||||
const chapter: Plugin.ChapterItem = {
|
||||
name: newChapterName,
|
||||
releaseTime: releaseDate,
|
||||
path: chapterUrl.replace(site, ''),
|
||||
chapterNumber: chapterIndex,
|
||||
};
|
||||
|
||||
novelChapters.push(chapter);
|
||||
}
|
||||
} // has parts that needs to be added individually
|
||||
else {
|
||||
const items =
|
||||
cheerio.find('i').parent().next().html()?.split('</a>') || [];
|
||||
items?.shift();
|
||||
const dates: string[] = [];
|
||||
items?.forEach(item => {
|
||||
// there is a date on every publish parts
|
||||
dates.push(
|
||||
item.substring(item.indexOf('(') + 1, item.indexOf(')')),
|
||||
);
|
||||
});
|
||||
const hrefs: string[] = [];
|
||||
cheerio
|
||||
.find('i')
|
||||
.parent()
|
||||
.next()
|
||||
.find('a')
|
||||
.each(function () {
|
||||
hrefs.push(this['attribs']['href']);
|
||||
});
|
||||
if (dates.length == hrefs.length)
|
||||
dates.forEach((date, index) => {
|
||||
const chapter: Plugin.ChapterItem = {
|
||||
name: newChapterName + ' (' + (index + 1) + ')',
|
||||
releaseTime: date,
|
||||
path: hrefs[index].replace(site, ''),
|
||||
chapterNumber: chapterIndex + (index + 1) / 1000,
|
||||
};
|
||||
novelChapters.push(chapter);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = novelChapters;
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const $ = await this.getCheerio(this.site + chapterPath);
|
||||
if (!$) throw new Error('Failed to load page (open in web view)');
|
||||
|
||||
$('.mistape_caption').remove();
|
||||
const chapterText =
|
||||
$('.chapter-content').html() || $('.entry-content').html() || '';
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
num: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (num !== 1) return []; // only 1 page of results
|
||||
const url = this.site + 'roman';
|
||||
const $ = await this.getCheerio(url);
|
||||
if (!$) throw new Error('Failed to load page (open in web view)');
|
||||
|
||||
let novels = this.parseNovels($, false);
|
||||
|
||||
novels = novels.filter(novel =>
|
||||
novel.name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.includes(
|
||||
searchTerm
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim(),
|
||||
),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
categorie_genre: {
|
||||
type: FilterTypes.Picker,
|
||||
label: 'Catégorie/Genre',
|
||||
value: 'all',
|
||||
options: [
|
||||
{ label: 'Tous', value: 'all' },
|
||||
{ label: '═══CATÉGORIES═══', value: 'categorie_roman' },
|
||||
{ label: 'Autre', value: 'c_autre' },
|
||||
{ label: 'Fille', value: 'c_fille' },
|
||||
{ label: 'Original', value: 'c_original' },
|
||||
{ label: 'Roman pour Adulte', value: 'c_roman-pour-adulte' },
|
||||
{ label: 'Seinen', value: 'c_seinen' },
|
||||
{ label: 'Shonen', value: 'c_shonen' },
|
||||
{ label: 'Xuanhuan', value: 'c_xuanhuan' },
|
||||
{ label: 'Yaoi', value: 'c_yaoi' },
|
||||
{ label: 'Yuri', value: 'c_yuri' },
|
||||
{ label: '═══GENRES═══', value: 'genre' },
|
||||
{ label: 'Action', value: 'g_action' },
|
||||
{ label: 'Adulte', value: 'g_adulte' },
|
||||
{ label: 'Anti-Héros', value: 'g_anti-heros' },
|
||||
{ label: 'Arts Martiaux', value: 'g_arts-martiaux' },
|
||||
{ label: 'Aventure', value: 'g_aventure' },
|
||||
{ label: 'Comédie', value: 'g_comedie' },
|
||||
{ label: 'Drame', value: 'g_drame' },
|
||||
{ label: 'Ecchi', value: 'g_ecchi' },
|
||||
{ label: 'Fantastique', value: 'g_fantastique' },
|
||||
{ label: 'Harem', value: 'g_harem' },
|
||||
{ label: 'Horreur', value: 'g_horreur' },
|
||||
{ label: 'Insectes', value: 'g_insectes' },
|
||||
{ label: 'Lolicon', value: 'g_lolicon' },
|
||||
{ label: 'Mature', value: 'g_mature' },
|
||||
{ label: 'Mecha', value: 'g_mecha' },
|
||||
{ label: 'Mystère', value: 'g_mystere' },
|
||||
{ label: 'Pas de harem', value: 'g_pas-de-harem' },
|
||||
{ label: 'Psychologique', value: 'g_psychologique' },
|
||||
{ label: 'Romance', value: 'g_romance' },
|
||||
{ label: 'Sci-fi', value: 'g_sci-fi' },
|
||||
{ label: 'Science-Fiction', value: 'g_science-fiction' },
|
||||
{ label: 'Shotacon', value: 'g_shotacon' },
|
||||
{ label: 'Shoujo Ai', value: 'g_shoujo-ai' },
|
||||
{ label: 'Smut', value: 'g_smut' },
|
||||
{ label: 'Surnaturel', value: 'g_surnaturel' },
|
||||
{ label: 'Tragédie', value: 'g_tragedie' },
|
||||
{ label: 'Tranche de vie', value: 'g_tranche-de-vie' },
|
||||
{ label: 'Vie scolaire', value: 'g_vie-scolaire' },
|
||||
{ label: 'Xuanhuan', value: 'g_xuanhuan' },
|
||||
],
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new NovelDeGlacePlugin();
|
||||
@@ -0,0 +1,206 @@
|
||||
import { CheerioAPI, load } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class NovhellPlugin implements Plugin.PluginBase {
|
||||
id = 'novhell';
|
||||
name = 'Novhell';
|
||||
icon = 'src/fr/novhell/icon.png';
|
||||
site = 'https://novhell.org';
|
||||
version = '1.0.1';
|
||||
|
||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||
const r = await fetchApi(url, {
|
||||
headers: { 'Accept-Encoding': 'deflate' },
|
||||
});
|
||||
const body = await r.text();
|
||||
const $ = load(body);
|
||||
return $;
|
||||
}
|
||||
|
||||
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo > 1) return [];
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
let novel: Plugin.NovelItem;
|
||||
const url = this.site;
|
||||
const $ = await this.getCheerio(url);
|
||||
$('article div div div figure').each((i, elem) => {
|
||||
let novelName = $(elem)
|
||||
.find('figcaption span strong')
|
||||
.first()
|
||||
.text()
|
||||
.trim();
|
||||
if (!novelName || novelName.trim() === '') {
|
||||
novelName = $(elem).find('figcaption a strong').first().text().trim();
|
||||
}
|
||||
const novelCover = $(elem).find('a img').attr('src');
|
||||
const novelUrl = $(elem).find('a').attr('href');
|
||||
|
||||
if (novelUrl && novelName) {
|
||||
novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: 'Sans titre',
|
||||
};
|
||||
|
||||
const $ = await this.getCheerio(this.site + novelPath);
|
||||
|
||||
novel.name =
|
||||
$('meta[property="og:title"]')
|
||||
.attr('content')
|
||||
?.replace('- NovHell', '') || '';
|
||||
novel.cover =
|
||||
$('section div div div div div img').first().attr('src') || defaultCover;
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
novel.author = $("strong:contains('Ecrit par ')")
|
||||
.parent()
|
||||
.text()
|
||||
.replace('Ecrit par ', '')
|
||||
.trim();
|
||||
|
||||
if (!novel.author) {
|
||||
novel.author = $("div p:contains('Auteur')")
|
||||
.text()
|
||||
.replace('Auteur', '')
|
||||
.replace(':', '')
|
||||
.trim();
|
||||
}
|
||||
if (!novel.author) {
|
||||
novel.author = $("div p:contains('Ecrit par :')")
|
||||
.text()
|
||||
.replace('Ecrit par :', '')
|
||||
.trim();
|
||||
}
|
||||
novel.genres = $("strong:contains('Genre')")
|
||||
.parent()
|
||||
.text()
|
||||
.replace('Genre', '')
|
||||
.replace(':', '')
|
||||
.trim();
|
||||
if (!novel.genres) {
|
||||
novel.genres = $("div p:contains('Genre')")
|
||||
.text()
|
||||
.replace('Genre', '')
|
||||
.replace(':', '')
|
||||
.trim();
|
||||
}
|
||||
novel.summary = $("strong:contains('Synopsis')")
|
||||
.parent()
|
||||
.parent()
|
||||
.text()
|
||||
.replace('Synopsis', '')
|
||||
.replace('Synopsis', '')
|
||||
.replace(':', '')
|
||||
.trim();
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
$('main div article div div section div div div div div p a').each(
|
||||
(i, elem) => {
|
||||
// Replace non-breaking spaces with a 'normal' space.
|
||||
const chapterName = $(elem)
|
||||
.text()
|
||||
.replace(/\u00A0/g, ' ')
|
||||
.trim();
|
||||
const chapterUrl = $(elem).attr('href');
|
||||
// Check if the chapter URL exists and contains the site name.
|
||||
if (chapterUrl && chapterUrl.includes(this.site)) {
|
||||
const regex = /Chapitre (\d+)/g;
|
||||
let chapterNumber = 0;
|
||||
let match;
|
||||
while ((match = regex.exec(chapterName)) !== null) {
|
||||
const number = parseInt(match[1]);
|
||||
chapterNumber += number;
|
||||
}
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
chapterNumber: chapterNumber,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Sort the chapters array based on the chapter numbers.
|
||||
// We retrieve the chapters in the order 1-6-11-16-21-......
|
||||
novel.chapters = chapters.sort((chapterA, chapterB) => {
|
||||
if (
|
||||
chapterA.chapterNumber !== undefined &&
|
||||
chapterB.chapterNumber !== undefined
|
||||
) {
|
||||
return chapterA.chapterNumber - chapterB.chapterNumber;
|
||||
}
|
||||
if (chapterA.chapterNumber === undefined) {
|
||||
return 1;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
});
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const $ = await this.getCheerio(this.site + chapterPath);
|
||||
const sections = $('main article div div section');
|
||||
if (sections) {
|
||||
const numberOfSection = sections.length;
|
||||
let title;
|
||||
let positionChapter = 2;
|
||||
|
||||
for (let i = 3; i <= 5; i++) {
|
||||
title = sections.eq(numberOfSection - i);
|
||||
if (title.find('h4').length !== 0) {
|
||||
positionChapter = i - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const chapter = sections.eq(numberOfSection - positionChapter);
|
||||
|
||||
if (title && chapter) {
|
||||
return (title.html() || '') + (chapter.html() || '');
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo !== 1) return [];
|
||||
|
||||
const popularNovels = this.popularNovels(1);
|
||||
|
||||
const novels = (await popularNovels).filter(novel =>
|
||||
novel.name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.includes(
|
||||
searchTerm
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim(),
|
||||
),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new NovhellPlugin();
|
||||
@@ -0,0 +1,275 @@
|
||||
import { CheerioAPI, load } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class PhenixScansTradPlugin implements Plugin.PluginBase {
|
||||
id = 'phenixscans';
|
||||
name = 'PhenixScans';
|
||||
icon = 'src/fr/phenixscans/icon.png';
|
||||
site = 'https://phenixscans.fr';
|
||||
version = '1.0.1';
|
||||
|
||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||
const r = await fetchApi(url);
|
||||
const body = await r.text();
|
||||
const $ = load(body);
|
||||
return $;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ showLatestNovels, filters }: Plugin.PopularNovelsOptions,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
let novel: Plugin.NovelItem;
|
||||
|
||||
let filter = '';
|
||||
for (const key in filters) {
|
||||
if (typeof filters[key].value === 'object') {
|
||||
for (const value of filters[key].value as string[]) {
|
||||
filter += `&genre%5B%5D=${value}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const order = showLatestNovels ? 'update' : 'popular';
|
||||
const url = `${this.site}/manga/?page=${pageNo}${filter}&status=&type=novel&order=${order}`;
|
||||
|
||||
const $ = await this.getCheerio(url);
|
||||
$('div div div div a').each((i, elem) => {
|
||||
const novelName = $(elem).attr('title')?.trim();
|
||||
const novelUrl = $(elem).attr('href');
|
||||
const novelCover = $(elem).find('div img').attr('src') || defaultCover;
|
||||
|
||||
if (novelUrl && novelName) {
|
||||
novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: 'Sans titre',
|
||||
};
|
||||
|
||||
const $ = await this.getCheerio(this.site + novelPath);
|
||||
|
||||
novel.name = $('h1[itemprop=name]').text().replace('– Novel', '').trim();
|
||||
novel.cover =
|
||||
$('div[itemprop=image] img').first().attr('src') || defaultCover;
|
||||
novel.author = $('.fmed b:contains(Auteur)+span').text().trim();
|
||||
novel.genres = $('.mgen a')
|
||||
.map(function () {
|
||||
return $(this).text();
|
||||
})
|
||||
.get()
|
||||
.join(', ');
|
||||
novel.summary = $('.entry-content[itemprop=description]').text().trim();
|
||||
novel.status = this.getStatus(
|
||||
$('.tsinfo .imptdt:contains(Statut)').text().replace('Statut', '').trim(),
|
||||
);
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
$('ul li:has(div.chbox):has(div.eph-num)').each((i, elem) => {
|
||||
const chapterName = $(elem).find('a .chapternum').text().trim();
|
||||
const chapterUrl = $(elem).find('a').attr('href');
|
||||
const releaseDate = this.parseDate($(elem).find('a .chapterdate').text());
|
||||
if (chapterUrl && chapterUrl.includes(this.site) && chapterName) {
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
releaseTime: releaseDate,
|
||||
});
|
||||
}
|
||||
});
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
parseDate(date: string): string {
|
||||
const monthMapping: Record<string, number> = {
|
||||
janvier: 1,
|
||||
fevrier: 2,
|
||||
mars: 3,
|
||||
avril: 4,
|
||||
mai: 5,
|
||||
juin: 6,
|
||||
juillet: 7,
|
||||
aout: 8,
|
||||
septembre: 9,
|
||||
octobre: 10,
|
||||
novembre: 11,
|
||||
decembre: 12,
|
||||
};
|
||||
|
||||
const [day, month, year] = date.split(' ');
|
||||
return dayjs(
|
||||
`${day} ${monthMapping[month.normalize('NFD').replace(/[\u0300-\u036f]/g, '')]} ${year}`,
|
||||
'D MMMM YYYY',
|
||||
).format('DD MMMM YYYY');
|
||||
}
|
||||
|
||||
getStatus(status: string) {
|
||||
const lowerCaseStatus = status.toLowerCase();
|
||||
const ongoing = ['en cours', 'en cours de publication'];
|
||||
const onhiatus = ['en pause', 'en attente'];
|
||||
const completed = ['complété', 'fini', 'achevé', 'terminé'];
|
||||
const cancelled = ['abandonné'];
|
||||
|
||||
if (ongoing.includes(lowerCaseStatus)) {
|
||||
return NovelStatus.Ongoing;
|
||||
} else if (onhiatus.includes(lowerCaseStatus)) {
|
||||
return NovelStatus.OnHiatus;
|
||||
} else if (completed.includes(lowerCaseStatus)) {
|
||||
return NovelStatus.Completed;
|
||||
} else if (cancelled.includes(lowerCaseStatus)) {
|
||||
return NovelStatus.Cancelled;
|
||||
}
|
||||
return NovelStatus.Unknown;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const $ = await this.getCheerio(this.site + chapterPath);
|
||||
return $('#readerarea').html() || '';
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo !== 1) return [];
|
||||
|
||||
const popularNovels = this.popularNovels(1, {
|
||||
showLatestNovels: true,
|
||||
filters: undefined,
|
||||
});
|
||||
|
||||
const novels = (await popularNovels).filter(novel =>
|
||||
novel.name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.includes(
|
||||
searchTerm
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim(),
|
||||
),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
genre: {
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
label: 'Genre',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: 'Action', value: 'action' },
|
||||
{
|
||||
label: 'Action Adventure Fantaisie Psychologique',
|
||||
value: 'action-adventure-fantaisie-psychologique',
|
||||
},
|
||||
{
|
||||
label: 'Action Arts martiaux Aventure Fantastique Surnaturel',
|
||||
value: 'action-arts-martiaux-aventure-fantastique-surnaturel',
|
||||
},
|
||||
{
|
||||
label: 'Action Aventure Fantastique',
|
||||
value: 'action-aventure-fantastique',
|
||||
},
|
||||
{ label: 'Action Drame Shônen', value: 'action-drame-shonen' },
|
||||
{ label: 'Adult', value: 'adult' },
|
||||
{ label: 'Adventure', value: 'adventure' },
|
||||
{ label: 'amitié', value: 'amitie' },
|
||||
{ label: 'amour', value: 'amour' },
|
||||
{ label: 'Art-martiaux', value: 'art-martiaux' },
|
||||
{ label: 'Arts-martiaux', value: 'arts-martiaux' },
|
||||
{ label: 'Aventure', value: 'aventure' },
|
||||
{ label: 'Combat', value: 'combat' },
|
||||
{ label: 'Comedie', value: 'comedie' },
|
||||
{ label: 'Comedy', value: 'comedy' },
|
||||
{ label: 'Demons', value: 'demons' },
|
||||
{ label: 'Dragon', value: 'dragon' },
|
||||
{ label: 'Drama', value: 'drama' },
|
||||
{ label: 'Drame', value: 'drame' },
|
||||
{ label: 'Ecchi', value: 'ecchi' },
|
||||
{ label: 'Fantaisie', value: 'fantaisie' },
|
||||
{ label: 'fantastique', value: 'fantastique' },
|
||||
{ label: 'Fantasy', value: 'fantasy' },
|
||||
{ label: 'Ghosts', value: 'ghosts' },
|
||||
{ label: 'Harem', value: 'harem' },
|
||||
{ label: 'Historical', value: 'historical' },
|
||||
{ label: 'Historique', value: 'historique' },
|
||||
{ label: 'Horreur', value: 'horreur' },
|
||||
{ label: 'Horror', value: 'horror' },
|
||||
{ label: 'Isekai', value: 'isekai' },
|
||||
{ label: 'Josei', value: 'josei' },
|
||||
{ label: 'longstrip art', value: 'longstrip-art' },
|
||||
{ label: 'Magic', value: 'magic' },
|
||||
{ label: 'Magie', value: 'magie' },
|
||||
{ label: 'Male Protagonist', value: 'male-protagonist' },
|
||||
{ label: 'manga', value: 'manga' },
|
||||
{ label: 'Manhwa', value: 'manhwa' },
|
||||
{ label: 'Manhwa Player', value: 'manhwa-player' },
|
||||
{ label: 'Manwha', value: 'manwha' },
|
||||
{ label: 'Martial Arts', value: 'martial-arts' },
|
||||
{ label: 'Mature', value: 'mature' },
|
||||
{ label: 'Monstre', value: 'monstre' },
|
||||
{ label: 'Murim', value: 'murim' },
|
||||
{ label: 'mystère', value: 'mystere' },
|
||||
{ label: 'Mystery', value: 'mystery' },
|
||||
{ label: 'Necromancien', value: 'necromancien' },
|
||||
{ label: 'necromencer', value: 'necromencer' },
|
||||
{ label: 'Novel', value: 'novel' },
|
||||
{ label: 'One Shot', value: 'one-shot' },
|
||||
{ label: 'Over Power MC', value: 'over-power-mc' },
|
||||
{ label: 'Partenaire', value: 'partenaire' },
|
||||
{ label: 'Player', value: 'player' },
|
||||
{ label: 'Player Manhwa', value: 'player-manhwa' },
|
||||
{ label: 'Portail', value: 'portail' },
|
||||
{ label: 'Psychological', value: 'psychological' },
|
||||
{ label: 'Psychologique', value: 'psychologique' },
|
||||
{ label: 'regresseur', value: 'regresseur' },
|
||||
{ label: 'régression', value: 'regression' },
|
||||
{ label: 'Réincarnation', value: 'reincarnation' },
|
||||
{ label: 'Returner', value: 'returner' },
|
||||
{ label: 'Romance', value: 'romance' },
|
||||
{ label: 'School Life', value: 'school-life' },
|
||||
{ label: 'Sci-fi', value: 'sci-fi' },
|
||||
{ label: 'Seinen', value: 'seinen' },
|
||||
{ label: 'Shôjo', value: 'shojo' },
|
||||
{ label: 'Shônen', value: 'shonen' },
|
||||
{ label: 'Shotacon', value: 'shotacon' },
|
||||
{ label: 'Shoujo', value: 'shoujo' },
|
||||
{ label: 'Shounen', value: 'shounen' },
|
||||
{ label: 'Slice of Life', value: 'slice-of-life' },
|
||||
{ label: 'Slide of Life', value: 'slide-of-life' },
|
||||
{ label: 'Smut', value: 'smut' },
|
||||
{ label: 'Sports', value: 'sports' },
|
||||
{ label: 'Supernatural', value: 'supernatural' },
|
||||
{ label: 'Surnaturel', value: 'surnaturel' },
|
||||
{ label: 'Système', value: 'systeme' },
|
||||
{ label: 'Tragédie', value: 'tragedie' },
|
||||
{ label: 'Tragedy', value: 'tragedy' },
|
||||
{ label: 'Webtoons', value: 'webtoons' },
|
||||
],
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new PhenixScansTradPlugin();
|
||||
@@ -0,0 +1,187 @@
|
||||
import { CheerioAPI, load } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class WarriorLegendTradPlugin implements Plugin.PluginBase {
|
||||
id = 'warriorlegendtrad';
|
||||
name = 'Warrior Legend Trad';
|
||||
icon = 'src/fr/warriorlegendtrad/icon.png';
|
||||
site = 'https://warriorlegendtrad.wordpress.com';
|
||||
version = '1.0.1';
|
||||
|
||||
regexAuthors = [/Auteur\u00A0:([^\n]*)/];
|
||||
|
||||
regexGenres = [/Genre :([^\n]*)/];
|
||||
|
||||
regexSummary = [/Synopsis\u00A0:([\s\S]*)index chapitre :/i];
|
||||
|
||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||
const r = await fetchApi(url);
|
||||
const body = await r.text();
|
||||
const $ = load(body);
|
||||
return $;
|
||||
}
|
||||
|
||||
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo > 2) return [];
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
let novel: Plugin.NovelItem;
|
||||
let url;
|
||||
// light novel
|
||||
if (pageNo === 1) {
|
||||
url = this.site + '/light-novel';
|
||||
}
|
||||
// Original creation
|
||||
else {
|
||||
url = this.site + '/crea';
|
||||
}
|
||||
|
||||
const $ = await this.getCheerio(url);
|
||||
$('div div div article').each((i, elem) => {
|
||||
const novelName = $(elem).find('.entry-wrapper h2').first().text().trim();
|
||||
const novelUrl = $(elem).find('.entry-wrapper h2 a').attr('href');
|
||||
const novelCover =
|
||||
$(elem).find('figure a img').attr('src') || defaultCover;
|
||||
|
||||
if (novelUrl && novelName) {
|
||||
novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: 'Sans titre',
|
||||
};
|
||||
|
||||
const $ = await this.getCheerio(this.site + novelPath);
|
||||
|
||||
novel.name = $('.site-main article header h1').text().trim();
|
||||
novel.cover =
|
||||
$('.site-main article figure img').first().attr('src') || defaultCover;
|
||||
const entryContentText = $('.entry-content').text();
|
||||
novel.author = this.extractInfo(entryContentText, this.regexAuthors);
|
||||
novel.genres = this.extractInfo(entryContentText, this.regexGenres);
|
||||
novel.summary = this.extractInfo(entryContentText, this.regexSummary);
|
||||
novel.status = this.getStatus(entryContentText);
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
$('div div article div')
|
||||
.find('h2 a, h3 a')
|
||||
.each((i, elem) => {
|
||||
const chapterName = $(elem).text().trim();
|
||||
const chapterUrl = $(elem).attr('href');
|
||||
const releaseDate = dayjs(
|
||||
chapterUrl?.substring(this.site.length + 1, this.site.length + 11),
|
||||
).format('DD MMMM YYYY');
|
||||
if (chapterUrl && chapterUrl.includes(this.site) && chapterName) {
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
releaseTime: releaseDate,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
//We sort by date because the elements are not in a fixed order,
|
||||
//and then by name because there are chapters with the same dates.
|
||||
novel.chapters = chapters.sort((a, b) => {
|
||||
if (!a.releaseTime) return 1;
|
||||
if (!b.releaseTime) return -1;
|
||||
const dateA = new Date(a.releaseTime).getTime();
|
||||
const dateB = new Date(b.releaseTime).getTime();
|
||||
const dateComparison = dateA - dateB;
|
||||
if (dateComparison === 0) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
return dateComparison;
|
||||
});
|
||||
return novel;
|
||||
}
|
||||
|
||||
extractInfo(text: string, regexes: RegExp[]): string {
|
||||
for (const regex of regexes) {
|
||||
const match = regex.exec(text);
|
||||
if (match !== null) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
getStatus(text: string) {
|
||||
const regexSummary = [
|
||||
/État sur le site :([^\n]*)/i,
|
||||
/état sur le site:([^\n]*)/i,
|
||||
];
|
||||
|
||||
const status = this.extractInfo(text, regexSummary);
|
||||
if (status.includes('en cours')) {
|
||||
return NovelStatus.Ongoing;
|
||||
} else if (status.includes('en pause')) {
|
||||
return NovelStatus.OnHiatus;
|
||||
} else if (status.includes('terminé')) {
|
||||
return NovelStatus.Completed;
|
||||
} else if (status.includes('abandonné')) {
|
||||
return NovelStatus.Cancelled;
|
||||
}
|
||||
|
||||
return NovelStatus.Ongoing;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const $ = await this.getCheerio(this.site + chapterPath);
|
||||
let contenuHtml = '';
|
||||
$('.entry-content')
|
||||
.contents()
|
||||
.each(function () {
|
||||
if (
|
||||
!$.html(this)?.startsWith('<div') &&
|
||||
!$.html(this)?.startsWith('<hr') &&
|
||||
!$.html(this)?.includes('<script')
|
||||
) {
|
||||
contenuHtml += $.html(this);
|
||||
}
|
||||
});
|
||||
return contenuHtml;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo !== 1) return [];
|
||||
|
||||
const popularNovels = this.popularNovels(1);
|
||||
|
||||
const novels = (await popularNovels).filter(novel =>
|
||||
novel.name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.includes(
|
||||
searchTerm
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim(),
|
||||
),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new WarriorLegendTradPlugin();
|
||||
@@ -0,0 +1,205 @@
|
||||
import { CheerioAPI, load } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class WuxialnscantradPlugin implements Plugin.PluginBase {
|
||||
id = 'wuxialnscantrad';
|
||||
name = 'WuxiaLnScantrad';
|
||||
icon = 'src/fr/wuxialnscantrad/icon.png';
|
||||
site = 'https://wuxialnscantrad.wordpress.com';
|
||||
version = '1.0.0';
|
||||
|
||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||
const r = await fetchApi(url, {
|
||||
headers: { 'Accept-Encoding': 'deflate' },
|
||||
});
|
||||
const body = await r.text();
|
||||
const $ = load(body);
|
||||
return $;
|
||||
}
|
||||
|
||||
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo > 1) return [];
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
let novel: Plugin.NovelItem;
|
||||
const url = this.site;
|
||||
const $ = await this.getCheerio(url);
|
||||
$('#menu-item-2210 ul li').each((i, elem) => {
|
||||
const novelName = $(elem).first().text().trim();
|
||||
const novelUrl = $(elem).find('a').attr('href');
|
||||
|
||||
if (novelUrl && novelName) {
|
||||
novel = {
|
||||
name: novelName,
|
||||
cover: defaultCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: 'Sans titre',
|
||||
};
|
||||
|
||||
const $ = await this.getCheerio(this.site + novelPath);
|
||||
|
||||
novel.name = $('.entry-title').text().trim();
|
||||
novel.cover =
|
||||
$('.entry-content p strong img').first().attr('src') ||
|
||||
$('.entry-content p img').first().attr('src');
|
||||
|
||||
const entryContentText = $('.entry-content').text();
|
||||
novel.author = this.getAuthor(entryContentText);
|
||||
novel.genres = this.getGenres(entryContentText);
|
||||
novel.artist = this.getArtist(entryContentText);
|
||||
novel.summary = this.getSummary(entryContentText);
|
||||
novel.status = this.getStatus(entryContentText);
|
||||
|
||||
const pathChapter = $('.entry-content ul').first().children('li');
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
pathChapter.each((i, elem) => {
|
||||
const chapterName = $(elem).text().trim();
|
||||
const chapterUrl = $(elem).find('a').attr('href');
|
||||
if (chapterUrl && chapterUrl.includes(this.site) && chapterName) {
|
||||
const pathchapter = chapterUrl.replace(this.site, '');
|
||||
// we do not take the paths already present
|
||||
if (!chapters.some(chap => chap.path === pathchapter)) {
|
||||
const releaseDate = dayjs(
|
||||
chapterUrl?.substring(this.site.length + 1, this.site.length + 11),
|
||||
).format('DD MMMM YYYY');
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: pathchapter,
|
||||
releaseTime: releaseDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
getAuthor(text: string) {
|
||||
const regex = /Auteur\(s\):\s*(.*)/;
|
||||
const match = regex.exec(text);
|
||||
let author = '';
|
||||
if (match !== null) {
|
||||
author = match[1].trim();
|
||||
}
|
||||
return author;
|
||||
}
|
||||
|
||||
getGenres(text: string) {
|
||||
const regex = /Genres:\s*(.*)/;
|
||||
const match = regex.exec(text);
|
||||
let genre = '';
|
||||
if (match !== null) {
|
||||
genre = match[1].trim();
|
||||
}
|
||||
return genre;
|
||||
}
|
||||
|
||||
getArtist(text: string) {
|
||||
const regex = /Artiste\(s\):\s*(.*)Genres/;
|
||||
const match = regex.exec(text);
|
||||
let artist = '';
|
||||
if (match !== null) {
|
||||
artist = match[1].trim();
|
||||
}
|
||||
return artist;
|
||||
}
|
||||
|
||||
getSummary(text: string) {
|
||||
const regexAuthors = [
|
||||
/Synopsis :([\s\S]*)Chapitres disponibles/,
|
||||
/Sypnopsis([\s\S]*)Sypnopsis officiel/,
|
||||
/Synopsis([\s\S]*)Chapitres disponibles/,
|
||||
];
|
||||
|
||||
for (const regex of regexAuthors) {
|
||||
const match = regex.exec(text);
|
||||
if (match !== null) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
getStatus(text: string) {
|
||||
const regex = /Statut:\s*(.*)/;
|
||||
const match = regex.exec(text);
|
||||
let status = '';
|
||||
if (match !== null) {
|
||||
status = match[1].trim();
|
||||
}
|
||||
switch (status) {
|
||||
case 'En cours':
|
||||
return NovelStatus.Ongoing;
|
||||
case 'Arrêté':
|
||||
return NovelStatus.Cancelled;
|
||||
case 'Terminé':
|
||||
return NovelStatus.Completed;
|
||||
default:
|
||||
return NovelStatus.Ongoing;
|
||||
}
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const $ = await this.getCheerio(this.site + chapterPath);
|
||||
|
||||
let contenuHtml = '';
|
||||
$('.entry-content')
|
||||
.contents()
|
||||
.each(function () {
|
||||
if ($(this).html()?.includes('<script')) {
|
||||
return false;
|
||||
}
|
||||
//Removing tags linked to navigation and unnecessary paragraphs.
|
||||
if (
|
||||
!$(this).html()?.includes('data-attachment-id="480') &&
|
||||
!$.html(this)?.includes('<hr>') &&
|
||||
!$.html(this)?.includes('<p> </p>')
|
||||
) {
|
||||
contenuHtml += $.html(this);
|
||||
}
|
||||
});
|
||||
return contenuHtml;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo !== 1) return [];
|
||||
|
||||
const popularNovels = this.popularNovels(1);
|
||||
|
||||
const novels = (await popularNovels).filter(novel =>
|
||||
novel.name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.includes(
|
||||
searchTerm
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim(),
|
||||
),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new WuxialnscantradPlugin();
|
||||
@@ -0,0 +1,296 @@
|
||||
import { Cheerio, CheerioAPI, load, Element } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class XiaowazPlugin implements Plugin.PluginBase {
|
||||
id = 'xiaowaz';
|
||||
name = 'Xiaowaz';
|
||||
icon = 'src/fr/xiaowaz/icon.png';
|
||||
site = 'https://xiaowaz.fr';
|
||||
version = '1.0.1';
|
||||
static novels: Plugin.NovelItem[] | undefined;
|
||||
|
||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||
let retries = 5; // when fetching for images the sites sometimes terminates the connection
|
||||
let returnError: any;
|
||||
while (retries > 0) {
|
||||
try {
|
||||
const r = await fetchApi(url);
|
||||
const body = await r.text();
|
||||
const $ = load(body);
|
||||
return $;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
returnError = error;
|
||||
retries--;
|
||||
// wait 1 second before retrying
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
throw new Error(returnError ? returnError : 'Error fetching the page');
|
||||
}
|
||||
|
||||
async getAllNovels(): Promise<Plugin.NovelItem[]> {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
let novel: Plugin.NovelItem;
|
||||
const $ = await this.getCheerio(this.site);
|
||||
|
||||
const categories: Cheerio<Element>[] = [
|
||||
$('li.page_item').find('a:contains("Séries")').parent(),
|
||||
$('li.page_item').find('a:contains("Créations")').parent(),
|
||||
$('li.page_item').find('a:contains("†")').parent(),
|
||||
];
|
||||
|
||||
categories.forEach(cheerio => {
|
||||
cheerio.find('ul.children li').each((i, elem) => {
|
||||
const novelName = $(elem).first().text().trim().replace('✔', '');
|
||||
const novelUrl = $(elem).find('a').attr('href');
|
||||
|
||||
// Douluo Dalu is no good
|
||||
if (novelUrl && novelName && novelName !== 'Douluo Dalu') {
|
||||
novel = {
|
||||
name: novelName,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
novels.push(novel);
|
||||
}
|
||||
});
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async getNovelsCovers(
|
||||
novels: Plugin.NovelItem[],
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
await Promise.all(
|
||||
novels.map(async novel => {
|
||||
const $novel = await this.getCheerio(this.site + novel.path);
|
||||
novel.cover =
|
||||
$novel('img[fetchpriority = "high"]').first().attr('src') ||
|
||||
$novel('img.aligncenter').first().attr('src') ||
|
||||
defaultCover;
|
||||
}),
|
||||
);
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
||||
const PAGE_SIZE = 5;
|
||||
|
||||
if (!XiaowazPlugin.novels) XiaowazPlugin.novels = await this.getAllNovels();
|
||||
let novels: Plugin.NovelItem[] = XiaowazPlugin.novels;
|
||||
|
||||
const totalPages = Math.ceil(novels.length / PAGE_SIZE);
|
||||
if (pageNo > totalPages) return [];
|
||||
|
||||
// splitting novel list to make fewer requests for getting images
|
||||
novels = novels.slice(PAGE_SIZE * (pageNo - 1), PAGE_SIZE * pageNo);
|
||||
|
||||
return await this.getNovelsCovers(novels);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: 'Sans titre',
|
||||
};
|
||||
|
||||
const $ = await this.getCheerio(this.site + novelPath);
|
||||
|
||||
novel.name = $('.card_title').text().trim();
|
||||
novel.cover =
|
||||
$('img[fetchpriority = "high"]').first().attr('src') ||
|
||||
$('img.aligncenter').first().attr('src') ||
|
||||
defaultCover;
|
||||
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
if (novel.name.charAt(novel.name.length - 1) === '✔') {
|
||||
novel.status = NovelStatus.Completed;
|
||||
novel.name = novel.name.slice(0, -1);
|
||||
} else if (novelPath.startsWith('/series-abandonnees')) {
|
||||
novel.status = NovelStatus.Cancelled;
|
||||
}
|
||||
|
||||
const entryContentText = $('.entry-content').text();
|
||||
novel.author = this.getAuthor(entryContentText);
|
||||
novel.genres = this.getGenres(entryContentText);
|
||||
if (novelPath.startsWith('/oeuvres-originales')) {
|
||||
novel.genres += novel.genres ? ', Oeuvre originale' : 'Oeuvre originale';
|
||||
}
|
||||
|
||||
const listeParagraphe: string[] = [];
|
||||
|
||||
const PARAGRAPH_EXCLUDE_LIST = [
|
||||
'Écrit par',
|
||||
'Ecrit par',
|
||||
'Sorties régulières',
|
||||
'Auteur\u00A0:',
|
||||
'Statut VO\u00A0:',
|
||||
'Nom utilisé\u00A0:',
|
||||
'Auteur original\u00A0:',
|
||||
'Auteur original de l’oeuvre',
|
||||
'851 chapitres en',
|
||||
'Index\u00A0:',
|
||||
];
|
||||
|
||||
$('.entry-content > p').each((index, element) => {
|
||||
const balise = $(element);
|
||||
|
||||
// remove chapter links
|
||||
if (balise.find('a[href*="xiaowaz.fr/articles"]').length !== 0)
|
||||
return false;
|
||||
|
||||
const textbalise = balise.text();
|
||||
if (PARAGRAPH_EXCLUDE_LIST.some(keyword => textbalise.includes(keyword)))
|
||||
return false;
|
||||
|
||||
if (
|
||||
!textbalise.includes('Genre') &&
|
||||
!textbalise.includes('Synopsis') &&
|
||||
//Managing the novel 'Rebirth of The Thief Who Roamed The World' to remove these three fields.
|
||||
!textbalise.includes('重生之賊行天下') &&
|
||||
!textbalise.includes('Rebirth of The Thief Who Roamed The World') &&
|
||||
!textbalise.includes(
|
||||
'Romance, Comédie, Action, VRMMO, Réincarnation, Futuriste',
|
||||
)
|
||||
)
|
||||
listeParagraphe.push(textbalise);
|
||||
});
|
||||
novel.summary = listeParagraphe.join('\n').trim();
|
||||
|
||||
//Search for chapter links within ul/li tags; otherwise, within p/a tags,
|
||||
//but not both at the same time because otherwise, on the TDG page, it redirects to a PDF download link.
|
||||
let pathChapter = $('.entry-content ul li a');
|
||||
if (pathChapter.length === 0) {
|
||||
pathChapter = $('.entry-content p a');
|
||||
}
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
pathChapter.each((i, elem) => {
|
||||
const chapterName = $(elem).text().trim();
|
||||
const chapterUrl = $(elem).attr('href');
|
||||
if (chapterUrl && chapterUrl.includes(this.site) && chapterName) {
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
getAuthor(text: string) {
|
||||
const regexAuthors = [
|
||||
/Écrit par([^\n]*). Traduction/i,
|
||||
/Écrit par([^\n]*)./i,
|
||||
/Auteur original de l’œuvre\u00A0:([^\n]*)VO/i,
|
||||
/Auteur\u00A0:([^\n]*)sur/,
|
||||
/Auteur\u00A0:([^\n]*)/,
|
||||
/Auteure\u00A0:\u00A0([^\n]*)/,
|
||||
/Auteur original de l’oeuvre\u00A0:([^\n]*)/i,
|
||||
/Auteur original\u00A0:([^\n]*)/i,
|
||||
];
|
||||
|
||||
for (const regex of regexAuthors) {
|
||||
const match = regex.exec(text);
|
||||
if (match !== null) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
getGenres(text: string) {
|
||||
// We handle several cases where there are multiple spellings for the word 'genre'. Genre, Genre :, Genres, Genres:
|
||||
const regex = /Genre((?:.*?\n)*?)\s*Synopsis\s*/;
|
||||
const match = regex.exec(text);
|
||||
let genre = '';
|
||||
if (match !== null) {
|
||||
genre = match[1]
|
||||
.replace('\u00A0', ' ')
|
||||
.replace('s :', '')
|
||||
.replace(':', '')
|
||||
.trim();
|
||||
if (genre.startsWith('s\n')) {
|
||||
genre = genre.replace(/^./, '').trim();
|
||||
}
|
||||
}
|
||||
return genre;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const $ = await this.getCheerio(this.site + chapterPath);
|
||||
|
||||
const startTag = $('.wp-post-navigation');
|
||||
const endTag = $('.abh_box.abh_box_down.abh_box_business');
|
||||
|
||||
const elementsBetweenTags: string[] = [];
|
||||
let footnotesElement: string | null = null;
|
||||
|
||||
if (startTag.length > 0 && endTag.length > 0) {
|
||||
let currentElement = startTag.next();
|
||||
while (currentElement.length > 0 && !currentElement.is(endTag)) {
|
||||
if (
|
||||
currentElement.find('p > a[href="https://ko-fi.com/wazouille"]')
|
||||
.length > 0
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (currentElement.hasClass('footnote_container_prepare')) {
|
||||
footnotesElement = $.html(currentElement);
|
||||
} else {
|
||||
let htmlCurrentElement = $.html(currentElement);
|
||||
htmlCurrentElement =
|
||||
htmlCurrentElement === '<p> </p>'
|
||||
? '<p/>'
|
||||
: htmlCurrentElement;
|
||||
elementsBetweenTags.push(htmlCurrentElement);
|
||||
}
|
||||
currentElement = currentElement.next();
|
||||
}
|
||||
}
|
||||
|
||||
// Place footnotes at the end of the chapter, not at the beginning
|
||||
if (footnotesElement) {
|
||||
elementsBetweenTags.push(footnotesElement);
|
||||
}
|
||||
|
||||
return elementsBetweenTags.join('\n').trim();
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo !== 1) return [];
|
||||
|
||||
if (!XiaowazPlugin.novels) XiaowazPlugin.novels = await this.getAllNovels();
|
||||
const popularNovels = XiaowazPlugin.novels;
|
||||
|
||||
// Normalize the text to remove accents and other special characters
|
||||
// This ensures that the search term and novel names are compared accurately
|
||||
const novels = popularNovels.filter(novel =>
|
||||
novel.name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.includes(
|
||||
searchTerm
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim(),
|
||||
),
|
||||
);
|
||||
|
||||
return await this.getNovelsCovers(novels);
|
||||
}
|
||||
}
|
||||
|
||||
export default new XiaowazPlugin();
|
||||
@@ -0,0 +1,497 @@
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import p_0 from '@plugins/arabic/ArNovel[madara]';
|
||||
import p_1 from '@plugins/arabic/Azora[madara]';
|
||||
import p_2 from '@plugins/arabic/HizoManga[madara]';
|
||||
import p_3 from '@plugins/arabic/KolNovel[lightnovelwp]';
|
||||
import p_4 from '@plugins/arabic/Novel4Up[madara]';
|
||||
import p_5 from '@plugins/arabic/NovelsParadise[lightnovelwp]';
|
||||
import p_6 from '@plugins/arabic/Olaoecyou[madara]';
|
||||
import p_7 from '@plugins/arabic/Riwyat[madara]';
|
||||
import p_8 from '@plugins/arabic/dilartube';
|
||||
import p_9 from '@plugins/arabic/rewayatclub';
|
||||
import p_10 from '@plugins/arabic/sunovels';
|
||||
import p_11 from '@plugins/chinese/69shu';
|
||||
import p_12 from '@plugins/chinese/69xinshu';
|
||||
import p_13 from '@plugins/chinese/Quanben';
|
||||
import p_14 from '@plugins/chinese/ixdzs8';
|
||||
import p_15 from '@plugins/chinese/linovel';
|
||||
import p_16 from '@plugins/chinese/linovelib';
|
||||
import p_17 from '@plugins/chinese/linovelib_tw';
|
||||
import p_18 from '@plugins/chinese/novel543';
|
||||
import p_19 from '@plugins/english/AllNovelFull[readnovelfull]';
|
||||
import p_20 from '@plugins/english/AllNovel[readnovelfull]';
|
||||
import p_21 from '@plugins/english/ArcaneTranslations[lightnovelwp]';
|
||||
import p_22 from '@plugins/english/BelleReservoir[madara]';
|
||||
import p_23 from '@plugins/english/BoxNovel[readnovelfull]';
|
||||
import p_24 from '@plugins/english/CPUnovel[lightnovelwp]';
|
||||
import p_25 from '@plugins/english/CitrusAurora[madara]';
|
||||
import p_26 from '@plugins/english/CoralBoutique[madara]';
|
||||
import p_27 from '@plugins/english/DaoNovel[madara]';
|
||||
import p_28 from '@plugins/english/DaoTranslate[lightnovelwp]';
|
||||
import p_29 from '@plugins/english/DaoistQuest[fictioneer]';
|
||||
import p_30 from '@plugins/english/DearestRosalie[fictioneer]';
|
||||
import p_31 from '@plugins/english/DragonTea[madara]';
|
||||
import p_32 from '@plugins/english/Dragonholic[madara]';
|
||||
import p_33 from '@plugins/english/DuskBlossoms[madara]';
|
||||
import p_34 from '@plugins/english/ElloTL[lightnovelwp]';
|
||||
import p_35 from '@plugins/english/Eternalune[madara]';
|
||||
import p_36 from '@plugins/english/EtudeTranslations[madara]';
|
||||
import p_37 from '@plugins/english/FanNovel[readwn]';
|
||||
import p_38 from '@plugins/english/FansMTL[readwn]';
|
||||
import p_39 from '@plugins/english/FansTranslations[madara]';
|
||||
import p_40 from '@plugins/english/FirstKissNovel[madara]';
|
||||
import p_41 from '@plugins/english/Foxaholic[madara]';
|
||||
import p_42 from '@plugins/english/FreeWebNovel[readnovelfull]';
|
||||
import p_43 from '@plugins/english/GalaxyTranslations[madara]';
|
||||
import p_44 from '@plugins/english/Guavaread[madara]';
|
||||
import p_45 from '@plugins/english/HiraethTranslation[madara]';
|
||||
import p_46 from '@plugins/english/HotNovelPub[hotnovelpub]';
|
||||
import p_47 from '@plugins/english/Ippotranslations[lightnovelwp]';
|
||||
import p_48 from '@plugins/english/KeopiTranslations[lightnovelwp]';
|
||||
import p_49 from '@plugins/english/KnoxT[lightnovelwp]';
|
||||
import p_50 from '@plugins/english/LazyGirlTranslations[lightnovelwp]';
|
||||
import p_51 from '@plugins/english/LibRead[readnovelfull]';
|
||||
import p_52 from '@plugins/english/LightNovelCave[lightnovelworld]';
|
||||
import p_53 from '@plugins/english/LightNovelHeaven[madara]';
|
||||
import p_54 from '@plugins/english/LightNovelPlus[readnovelfull]';
|
||||
import p_55 from '@plugins/english/LightNovelPubVip[lightnovelworld]';
|
||||
import p_56 from '@plugins/english/LightNovelUpdates[madara]';
|
||||
import p_57 from '@plugins/english/LightNovelWord[lightnovelworld]';
|
||||
import p_58 from '@plugins/english/LilyontheValley[fictioneer]';
|
||||
import p_59 from '@plugins/english/Ltnovel[readwn]';
|
||||
import p_60 from '@plugins/english/LunarLetters[madara]';
|
||||
import p_61 from '@plugins/english/MTLNovel[madara]';
|
||||
import p_62 from '@plugins/english/MTLNovel[mtlnovel]';
|
||||
import p_63 from '@plugins/english/Meownovel[madara]';
|
||||
import p_64 from '@plugins/english/MoonlightNovels[lightnovelwp]';
|
||||
import p_65 from '@plugins/english/MostNovel[madara]';
|
||||
import p_66 from '@plugins/english/MysticalSeries[madara]';
|
||||
import p_67 from '@plugins/english/NeoSekaiTranslations[madara]';
|
||||
import p_68 from '@plugins/english/NitroManga[madara]';
|
||||
import p_69 from '@plugins/english/NobleMTL[lightnovelwp]';
|
||||
import p_70 from '@plugins/english/NovelBin[readnovelfull]';
|
||||
import p_71 from '@plugins/english/NovelCool[novelcool]';
|
||||
import p_72 from '@plugins/english/NovelFull[readnovelfull]';
|
||||
import p_73 from '@plugins/english/NovelLib[fictioneer]';
|
||||
import p_74 from '@plugins/english/NovelMultiverse[madara]';
|
||||
import p_75 from '@plugins/english/NovelOnline';
|
||||
import p_76 from '@plugins/english/NovelTranslate[madara]';
|
||||
import p_77 from '@plugins/english/NovelsKnight[lightnovelwp]';
|
||||
import p_78 from '@plugins/english/PandaMachineTranslations[lightnovelwp]';
|
||||
import p_79 from '@plugins/english/PastelTales[madara]';
|
||||
import p_80 from '@plugins/english/PenguinSquad[fictioneer]';
|
||||
import p_81 from '@plugins/english/Prizma[fictioneer]';
|
||||
import p_82 from '@plugins/english/Ranobes[ranobes]';
|
||||
import p_83 from '@plugins/english/Ranovel[madara]';
|
||||
import p_84 from '@plugins/english/ReadFanfic[madara]';
|
||||
import p_85 from '@plugins/english/ReadNovelFull[readnovelfull]';
|
||||
import p_86 from '@plugins/english/RequiemTranslations[lightnovelwp]';
|
||||
import p_87 from '@plugins/english/SalmonLatte[madara]';
|
||||
import p_88 from '@plugins/english/SleepyTranslations[madara]';
|
||||
import p_89 from '@plugins/english/SonicMTL[madara]';
|
||||
import p_90 from '@plugins/english/SrankManga[madara]';
|
||||
import p_91 from '@plugins/english/StorySeedling';
|
||||
import p_92 from '@plugins/english/SweetEscape[madara]';
|
||||
import p_93 from '@plugins/english/SystemTranslation[lightnovelwp]';
|
||||
import p_94 from '@plugins/english/TranslatinOtaku[madara]';
|
||||
import p_95 from '@plugins/english/TranslationWeaver[lightnovelwp]';
|
||||
import p_96 from '@plugins/english/UniversalNovel[lightnovelwp]';
|
||||
import p_97 from '@plugins/english/VandyTranslate[lightnovelwp]';
|
||||
import p_98 from '@plugins/english/VioletLily[madara]';
|
||||
import p_99 from '@plugins/english/WebNovelLover[madara]';
|
||||
import p_100 from '@plugins/english/WebNovelPub[lightnovelworld]';
|
||||
import p_101 from '@plugins/english/WebNovelTranslation[madara]';
|
||||
import p_102 from '@plugins/english/WhiteMoonlightNovels[lightnovelwp]';
|
||||
import p_103 from '@plugins/english/WooksTeahouse[madara]';
|
||||
import p_104 from '@plugins/english/WordExcerpt[madara]';
|
||||
import p_105 from '@plugins/english/WuxiaSpace[readwn]';
|
||||
import p_106 from '@plugins/english/WuxiaV[readwn]';
|
||||
import p_107 from '@plugins/english/WuxiaWorldSite[madara]';
|
||||
import p_108 from '@plugins/english/Wuxiabox[readwn]';
|
||||
import p_109 from '@plugins/english/Wuxiafox[readwn]';
|
||||
import p_110 from '@plugins/english/ZetroTranslation[madara]';
|
||||
import p_111 from '@plugins/english/ao3';
|
||||
import p_112 from '@plugins/english/bestlightnovel';
|
||||
import p_113 from '@plugins/english/chrysanthemumgarden';
|
||||
import p_114 from '@plugins/english/divinedaolibrary';
|
||||
import p_115 from '@plugins/english/dreambigtl';
|
||||
import p_116 from '@plugins/english/earlynovel';
|
||||
import p_117 from '@plugins/english/faqwikius';
|
||||
import p_118 from '@plugins/english/fenrirrealm';
|
||||
import p_119 from '@plugins/english/fictionzone';
|
||||
import p_120 from '@plugins/english/foxteller';
|
||||
import p_121 from '@plugins/english/genesis';
|
||||
import p_122 from '@plugins/english/inkitt';
|
||||
import p_123 from '@plugins/english/kdtnovels';
|
||||
import p_124 from '@plugins/english/lightnovelpub';
|
||||
import p_125 from '@plugins/english/lightnoveltranslation';
|
||||
import p_126 from '@plugins/english/lnmtl';
|
||||
import p_127 from '@plugins/english/mtlreader';
|
||||
import p_128 from '@plugins/english/mvlempyr';
|
||||
import p_129 from '@plugins/english/novelbuddy';
|
||||
import p_130 from '@plugins/english/novelfire';
|
||||
import p_131 from '@plugins/english/novelhall';
|
||||
import p_132 from '@plugins/english/novelight';
|
||||
import p_133 from '@plugins/english/novelupdates';
|
||||
import p_134 from '@plugins/english/pawread';
|
||||
import p_135 from '@plugins/english/rainofsnow';
|
||||
import p_136 from '@plugins/english/readfrom';
|
||||
import p_137 from '@plugins/english/readlitenovel';
|
||||
import p_138 from '@plugins/english/reaperscans';
|
||||
import p_139 from '@plugins/english/relibrary';
|
||||
import p_140 from '@plugins/english/royalroad';
|
||||
import p_141 from '@plugins/english/scribblehub';
|
||||
import p_142 from '@plugins/english/vynovel';
|
||||
import p_143 from '@plugins/english/webnovel';
|
||||
import p_144 from '@plugins/english/wtrlab';
|
||||
import p_145 from '@plugins/english/wuxiaworld';
|
||||
import p_146 from '@plugins/french/LighNovelFR[lightnovelwp]';
|
||||
import p_147 from '@plugins/french/MTLNovel(FR)[mtlnovel]';
|
||||
import p_148 from '@plugins/french/MassNovel[madara]';
|
||||
import p_149 from '@plugins/french/WorldNovel[madara]';
|
||||
import p_150 from '@plugins/french/chireads';
|
||||
import p_151 from '@plugins/french/harkeneliwood';
|
||||
import p_152 from '@plugins/french/kisswood';
|
||||
import p_153 from '@plugins/french/noveldeglace';
|
||||
import p_154 from '@plugins/french/novhell';
|
||||
import p_155 from '@plugins/french/phenixscans';
|
||||
import p_156 from '@plugins/french/warriorlegendtrad';
|
||||
import p_157 from '@plugins/french/wuxialnscantrad';
|
||||
import p_158 from '@plugins/french/xiaowaz';
|
||||
import p_159 from '@plugins/indonesian/BacaLightNovel[lightnovelwp]';
|
||||
import p_160 from '@plugins/indonesian/MTLNovel(ID)[mtlnovel]';
|
||||
import p_161 from '@plugins/indonesian/MeioNovel[madara]';
|
||||
import p_162 from '@plugins/indonesian/NovelBookID[madara]';
|
||||
import p_163 from '@plugins/indonesian/Risenovel[madara]';
|
||||
import p_164 from '@plugins/indonesian/SekteNovel[lightnovelwp]';
|
||||
import p_165 from '@plugins/indonesian/WBNovel[madara]';
|
||||
import p_166 from '@plugins/indonesian/indowebnovel';
|
||||
import p_167 from '@plugins/indonesian/novelringan';
|
||||
import p_168 from '@plugins/indonesian/sakuranovel';
|
||||
import p_169 from '@plugins/japanese/Syosetu';
|
||||
import p_170 from '@plugins/japanese/kakuyomu';
|
||||
import p_171 from '@plugins/korean/Agitoon';
|
||||
import p_172 from '@plugins/korean/FortuneEternal[madara]';
|
||||
import p_173 from '@plugins/multi/komga';
|
||||
import p_174 from '@plugins/polish/novelki';
|
||||
import p_175 from '@plugins/portuguese/BetterNovels[lightnovelwp]';
|
||||
import p_176 from '@plugins/portuguese/CentralNovel[lightnovelwp]';
|
||||
import p_177 from '@plugins/portuguese/Kiniga[madara]';
|
||||
import p_178 from '@plugins/portuguese/LaNovels[hotnovelpub]';
|
||||
import p_179 from '@plugins/portuguese/LightNovelBrasil[lightnovelwp]';
|
||||
import p_180 from '@plugins/portuguese/MTLNovel(PT)[mtlnovel]';
|
||||
import p_181 from '@plugins/portuguese/blogdoamonnovels';
|
||||
import p_182 from '@plugins/portuguese/novelmania';
|
||||
import p_183 from '@plugins/portuguese/tsundoku';
|
||||
import p_184 from '@plugins/russian/Bookhamster[ifreedom]';
|
||||
import p_185 from '@plugins/russian/Erolate[rulate]';
|
||||
import p_186 from '@plugins/russian/EzNovels[hotnovelpub]';
|
||||
import p_187 from '@plugins/russian/LitSpace';
|
||||
import p_188 from '@plugins/russian/MTLNovel(RU)[mtlnovel]';
|
||||
import p_189 from '@plugins/russian/NovelCool(RU)[novelcool]';
|
||||
import p_190 from '@plugins/russian/Ranobes(RU)[ranobes]';
|
||||
import p_191 from '@plugins/russian/Rulate[rulate]';
|
||||
import p_192 from '@plugins/russian/authortoday';
|
||||
import p_193 from '@plugins/russian/bookriver';
|
||||
import p_194 from '@plugins/russian/ficbook';
|
||||
import p_195 from '@plugins/russian/jaomix';
|
||||
import p_196 from '@plugins/russian/neobook';
|
||||
import p_197 from '@plugins/russian/novelOvh';
|
||||
import p_198 from '@plugins/russian/novelTL';
|
||||
import p_199 from '@plugins/russian/ranobehub';
|
||||
import p_200 from '@plugins/russian/ranobelib';
|
||||
import p_201 from '@plugins/russian/ranoberf';
|
||||
import p_202 from '@plugins/russian/renovels';
|
||||
import p_203 from '@plugins/russian/ruvers';
|
||||
import p_204 from '@plugins/russian/topliba';
|
||||
import p_205 from '@plugins/russian/zelluloza';
|
||||
import p_206 from '@plugins/russian/СвободныйМирРанобэ[ifreedom]';
|
||||
import p_207 from '@plugins/spanish/AllNovelRead[lightnovelwp]';
|
||||
import p_208 from '@plugins/spanish/AnimesHoy12[madara]';
|
||||
import p_209 from '@plugins/spanish/LightNovelDaily[hotnovelpub]';
|
||||
import p_210 from '@plugins/spanish/MTLNovel(ES)[mtlnovel]';
|
||||
import p_211 from '@plugins/spanish/PanchoTranslations[madara]';
|
||||
import p_212 from '@plugins/spanish/TC&Sega[lightnovelwp]';
|
||||
import p_213 from '@plugins/spanish/TraduccionesAmistosas[madara]';
|
||||
import p_214 from '@plugins/spanish/hasutl';
|
||||
import p_215 from '@plugins/spanish/novelasligera';
|
||||
import p_216 from '@plugins/spanish/novelawuxia';
|
||||
import p_217 from '@plugins/spanish/oasistranslations';
|
||||
import p_218 from '@plugins/spanish/skynovels';
|
||||
import p_219 from '@plugins/spanish/tunovelaligera';
|
||||
import p_220 from '@plugins/spanish/yukitls';
|
||||
import p_221 from '@plugins/thai/NovelLucky[madara]';
|
||||
import p_222 from '@plugins/thai/NovelPDF[madara]';
|
||||
import p_223 from '@plugins/turkish/ArazNovel[madara]';
|
||||
import p_224 from '@plugins/turkish/EKTAPLAR[madara]';
|
||||
import p_225 from '@plugins/turkish/KodeksLibrary[lightnovelwp]';
|
||||
import p_226 from '@plugins/turkish/MangaTR';
|
||||
import p_227 from '@plugins/turkish/NABSCANS[madara]';
|
||||
import p_228 from '@plugins/turkish/Namevt[lightnovelwp]';
|
||||
import p_229 from '@plugins/turkish/NovelTR[lightnovelwp]';
|
||||
import p_230 from '@plugins/turkish/Noveloku[madara]';
|
||||
import p_231 from '@plugins/turkish/RagnarScans[madara]';
|
||||
import p_232 from '@plugins/turkish/ThNovels[hotnovelpub]';
|
||||
import p_233 from '@plugins/turkish/TurkceLightNovels[madara]';
|
||||
import p_234 from '@plugins/turkish/WebNovelOku[madara]';
|
||||
import p_235 from '@plugins/turkish/epiknovel';
|
||||
import p_236 from '@plugins/turkish/kakikata[madara]';
|
||||
import p_237 from '@plugins/ukrainian/bakainua';
|
||||
import p_238 from '@plugins/ukrainian/smakolykytl';
|
||||
import p_239 from '@plugins/ukrainian/uaranobeclub';
|
||||
import p_240 from '@plugins/vietnamese/LNHako';
|
||||
import p_241 from '@plugins/vietnamese/Truyenconect';
|
||||
import p_242 from '@plugins/vietnamese/lightnovelvn';
|
||||
import p_243 from '@plugins/vietnamese/nettruyen';
|
||||
import p_244 from '@plugins/vietnamese/truyenchu';
|
||||
import p_245 from '@plugins/vietnamese/truyenfull';
|
||||
|
||||
const PLUGINS: Plugin.PluginBase[] = [
|
||||
p_0,
|
||||
p_1,
|
||||
p_2,
|
||||
p_3,
|
||||
p_4,
|
||||
p_5,
|
||||
p_6,
|
||||
p_7,
|
||||
p_8,
|
||||
p_9,
|
||||
p_10,
|
||||
p_11,
|
||||
p_12,
|
||||
p_13,
|
||||
p_14,
|
||||
p_15,
|
||||
p_16,
|
||||
p_17,
|
||||
p_18,
|
||||
p_19,
|
||||
p_20,
|
||||
p_21,
|
||||
p_22,
|
||||
p_23,
|
||||
p_24,
|
||||
p_25,
|
||||
p_26,
|
||||
p_27,
|
||||
p_28,
|
||||
p_29,
|
||||
p_30,
|
||||
p_31,
|
||||
p_32,
|
||||
p_33,
|
||||
p_34,
|
||||
p_35,
|
||||
p_36,
|
||||
p_37,
|
||||
p_38,
|
||||
p_39,
|
||||
p_40,
|
||||
p_41,
|
||||
p_42,
|
||||
p_43,
|
||||
p_44,
|
||||
p_45,
|
||||
p_46,
|
||||
p_47,
|
||||
p_48,
|
||||
p_49,
|
||||
p_50,
|
||||
p_51,
|
||||
p_52,
|
||||
p_53,
|
||||
p_54,
|
||||
p_55,
|
||||
p_56,
|
||||
p_57,
|
||||
p_58,
|
||||
p_59,
|
||||
p_60,
|
||||
p_61,
|
||||
p_62,
|
||||
p_63,
|
||||
p_64,
|
||||
p_65,
|
||||
p_66,
|
||||
p_67,
|
||||
p_68,
|
||||
p_69,
|
||||
p_70,
|
||||
p_71,
|
||||
p_72,
|
||||
p_73,
|
||||
p_74,
|
||||
p_75,
|
||||
p_76,
|
||||
p_77,
|
||||
p_78,
|
||||
p_79,
|
||||
p_80,
|
||||
p_81,
|
||||
p_82,
|
||||
p_83,
|
||||
p_84,
|
||||
p_85,
|
||||
p_86,
|
||||
p_87,
|
||||
p_88,
|
||||
p_89,
|
||||
p_90,
|
||||
p_91,
|
||||
p_92,
|
||||
p_93,
|
||||
p_94,
|
||||
p_95,
|
||||
p_96,
|
||||
p_97,
|
||||
p_98,
|
||||
p_99,
|
||||
p_100,
|
||||
p_101,
|
||||
p_102,
|
||||
p_103,
|
||||
p_104,
|
||||
p_105,
|
||||
p_106,
|
||||
p_107,
|
||||
p_108,
|
||||
p_109,
|
||||
p_110,
|
||||
p_111,
|
||||
p_112,
|
||||
p_113,
|
||||
p_114,
|
||||
p_115,
|
||||
p_116,
|
||||
p_117,
|
||||
p_118,
|
||||
p_119,
|
||||
p_120,
|
||||
p_121,
|
||||
p_122,
|
||||
p_123,
|
||||
p_124,
|
||||
p_125,
|
||||
p_126,
|
||||
p_127,
|
||||
p_128,
|
||||
p_129,
|
||||
p_130,
|
||||
p_131,
|
||||
p_132,
|
||||
p_133,
|
||||
p_134,
|
||||
p_135,
|
||||
p_136,
|
||||
p_137,
|
||||
p_138,
|
||||
p_139,
|
||||
p_140,
|
||||
p_141,
|
||||
p_142,
|
||||
p_143,
|
||||
p_144,
|
||||
p_145,
|
||||
p_146,
|
||||
p_147,
|
||||
p_148,
|
||||
p_149,
|
||||
p_150,
|
||||
p_151,
|
||||
p_152,
|
||||
p_153,
|
||||
p_154,
|
||||
p_155,
|
||||
p_156,
|
||||
p_157,
|
||||
p_158,
|
||||
p_159,
|
||||
p_160,
|
||||
p_161,
|
||||
p_162,
|
||||
p_163,
|
||||
p_164,
|
||||
p_165,
|
||||
p_166,
|
||||
p_167,
|
||||
p_168,
|
||||
p_169,
|
||||
p_170,
|
||||
p_171,
|
||||
p_172,
|
||||
p_173,
|
||||
p_174,
|
||||
p_175,
|
||||
p_176,
|
||||
p_177,
|
||||
p_178,
|
||||
p_179,
|
||||
p_180,
|
||||
p_181,
|
||||
p_182,
|
||||
p_183,
|
||||
p_184,
|
||||
p_185,
|
||||
p_186,
|
||||
p_187,
|
||||
p_188,
|
||||
p_189,
|
||||
p_190,
|
||||
p_191,
|
||||
p_192,
|
||||
p_193,
|
||||
p_194,
|
||||
p_195,
|
||||
p_196,
|
||||
p_197,
|
||||
p_198,
|
||||
p_199,
|
||||
p_200,
|
||||
p_201,
|
||||
p_202,
|
||||
p_203,
|
||||
p_204,
|
||||
p_205,
|
||||
p_206,
|
||||
p_207,
|
||||
p_208,
|
||||
p_209,
|
||||
p_210,
|
||||
p_211,
|
||||
p_212,
|
||||
p_213,
|
||||
p_214,
|
||||
p_215,
|
||||
p_216,
|
||||
p_217,
|
||||
p_218,
|
||||
p_219,
|
||||
p_220,
|
||||
p_221,
|
||||
p_222,
|
||||
p_223,
|
||||
p_224,
|
||||
p_225,
|
||||
p_226,
|
||||
p_227,
|
||||
p_228,
|
||||
p_229,
|
||||
p_230,
|
||||
p_231,
|
||||
p_232,
|
||||
p_233,
|
||||
p_234,
|
||||
p_235,
|
||||
p_236,
|
||||
p_237,
|
||||
p_238,
|
||||
p_239,
|
||||
p_240,
|
||||
p_241,
|
||||
p_242,
|
||||
p_243,
|
||||
p_244,
|
||||
p_245,
|
||||
];
|
||||
export default PLUGINS;
|
||||
@@ -0,0 +1,223 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
|
||||
class IndoWebNovel implements Plugin.PluginBase {
|
||||
id = 'IDWN.id';
|
||||
name = 'IndoWebNovel';
|
||||
icon = 'src/id/indowebnovel/icon.png';
|
||||
site = 'https://indowebnovel.id/';
|
||||
version = '1.2.3';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.flexbox2-item').each((i, el) => {
|
||||
const novelName = loadedCheerio(el)
|
||||
.find('.flexbox2-title span')
|
||||
.first()
|
||||
.text();
|
||||
const novelCover = loadedCheerio(el).find('img').attr('src');
|
||||
const novelUrl = loadedCheerio(el)
|
||||
.find('.flexbox2-content > a')
|
||||
.attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
novels.push({
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.slice(this.site.length),
|
||||
});
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page = 1,
|
||||
// { filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
/*
|
||||
let link = `${this.site}advanced-search/page/${page}/?title=&author=&yearx=`;
|
||||
link += `&status=${filters.status.value}`;
|
||||
link += `&type=${filters.type.value}`;
|
||||
link += `&order=${filters.sort.value}`;
|
||||
|
||||
if (filters.lang.value.length)
|
||||
link += filters.lang.value.map(i => `&country[]=${i}`).join('');
|
||||
if (filters.genre.value.length)
|
||||
link += filters.genre.value.map(i => `&genre[]=${i}`).join('');
|
||||
*/
|
||||
const link = this.site + `page/${page}/?s`;
|
||||
const result = await fetchApi(link);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const result = await fetchApi(this.site + novelPath);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
loadedCheerio('.series-synops div').remove();
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.series-title h2').text().trim() || 'Untitled',
|
||||
cover: loadedCheerio('.series-thumb img').attr('src'),
|
||||
author: loadedCheerio(".series-infolist li:contains('Author') span")
|
||||
.text()
|
||||
.trim(),
|
||||
status:
|
||||
loadedCheerio('.status').text().trim() === 'Completed'
|
||||
? NovelStatus.Completed
|
||||
: NovelStatus.Ongoing,
|
||||
summary: loadedCheerio('.series-synops').text().trim(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
novel.genres = loadedCheerio('.series-genres a')
|
||||
.map((i, el) => loadedCheerio(el).text().trim())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('.series-chapterlist li').each((i, el) => {
|
||||
const chapterName = loadedCheerio(el).find('a').text().trim();
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href');
|
||||
|
||||
if (!chapterUrl) return;
|
||||
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.slice(this.site.length),
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapters.reverse();
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(this.site + chapterPath);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const chapterText = loadedCheerio('.adsads').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page = 1,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
/*
|
||||
let link = `${this.site}advanced-search/page/${page}/?title=${searchTerm}&author=&yearx=`;
|
||||
link += `&status=${this.filters.status.value}`;
|
||||
link += `&type=${this.filters.type.value}`;
|
||||
link += `&order=${this.filters.sort.value}`;
|
||||
link += this.filters.lang.value.map(i => `&country[]=${i}`).join('');
|
||||
*/
|
||||
const link = this.site + `page/${page}/?s=${searchTerm}`;
|
||||
const result = await fetchApi(link);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
// filters = {
|
||||
// status: {
|
||||
// value: '',
|
||||
// label: 'Status',
|
||||
// options: [
|
||||
// { label: 'All', value: '' },
|
||||
// { label: 'Ongoing', value: 'ongoing' },
|
||||
// { label: 'Completed', value: 'completed' },
|
||||
// ],
|
||||
// type: FilterTypes.Picker,
|
||||
// },
|
||||
// type: {
|
||||
// value: '',
|
||||
// label: 'Type',
|
||||
// options: [
|
||||
// { label: 'All', value: '' },
|
||||
// { label: 'Web Novel', value: 'Web+Novel' },
|
||||
// { label: 'Light Novel', value: 'Light+Novel' },
|
||||
// ],
|
||||
// type: FilterTypes.Picker,
|
||||
// },
|
||||
// sort: {
|
||||
// value: 'rating',
|
||||
// label: 'Order By',
|
||||
// options: [
|
||||
// { label: 'A-Z', value: 'title' },
|
||||
// { label: 'Z-A', value: 'titlereverse' },
|
||||
// { label: 'Latest Update', value: 'update' },
|
||||
// { label: 'Latest Added', value: 'latest' },
|
||||
// { label: 'Popular', value: 'popular' },
|
||||
// { label: 'Rating', value: 'rating' },
|
||||
// ],
|
||||
// type: FilterTypes.Picker,
|
||||
// },
|
||||
// lang: {
|
||||
// value: ['china', 'jepang', 'korea', 'unknown'],
|
||||
// label: 'Country',
|
||||
// options: [
|
||||
// { label: 'China', value: 'china' },
|
||||
// { label: 'Jepang', value: 'jepang' },
|
||||
// { label: 'Korea', value: 'korea' },
|
||||
// { label: 'Unknown', value: 'unknown' },
|
||||
// ],
|
||||
// type: FilterTypes.CheckboxGroup,
|
||||
// },
|
||||
// genre: {
|
||||
// value: [],
|
||||
// label: 'Genres',
|
||||
// options: [
|
||||
// { label: 'Action', value: 'action' },
|
||||
// { label: 'Adult', value: 'adult' },
|
||||
// { label: 'Adventure', value: 'adventure' },
|
||||
// { label: 'Comedy', value: 'comedy' },
|
||||
// { label: 'Drama', value: 'drama' },
|
||||
// { label: 'Ecchi', value: 'ecchi' },
|
||||
// { label: 'Fantasy', value: 'fantasy' },
|
||||
// { label: 'Gender Bender', value: 'gender-bender' },
|
||||
// { label: 'Harem', value: 'harem' },
|
||||
// { label: 'Horror', value: 'horror' },
|
||||
// { label: 'Josei', value: 'josei' },
|
||||
// { label: 'Josei', value: 'josei' },
|
||||
// { 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: 'Smut', value: 'smut' },
|
||||
// { label: 'Supernatural', value: 'supernatural' },
|
||||
// { label: 'Tragedy', value: 'tragedy' },
|
||||
// { label: 'Wuxia', value: 'wuxia' },
|
||||
// { label: 'Xianxia', value: 'xianxia' },
|
||||
// { label: 'Xuanhuan', value: 'xuanhuan' },
|
||||
// ],
|
||||
// type: FilterTypes.CheckboxGroup,
|
||||
// },
|
||||
// } satisfies Filters;
|
||||
}
|
||||
|
||||
export default new IndoWebNovel();
|
||||
@@ -0,0 +1,256 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
|
||||
class NovelRingan implements Plugin.PluginBase {
|
||||
id = 'novelringan.com';
|
||||
name = 'NovelRingan';
|
||||
icon = 'src/id/novelringan/icon.png';
|
||||
site = 'https://novelringan.com/';
|
||||
version = '1.0.0';
|
||||
coverUriPrefix = 'https://i0.wp.com/novelringan.com/wp-content/uploads/';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('article.post').each((idx, ele) => {
|
||||
const novelName = loadedCheerio(ele).find('.entry-title').text()?.trim();
|
||||
const novelCover =
|
||||
this.coverUriPrefix + loadedCheerio(ele).find('img').attr('data-sxrx');
|
||||
const novelUrl = loadedCheerio(ele).find('h2 > a').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}advanced-search/page/${page}/?title`;
|
||||
link += `&status=${filters.status.value}`;
|
||||
link += `&order=${filters.sort.value}`;
|
||||
|
||||
if (filters.type.value.length)
|
||||
link += filters.type.value.map(i => `&type[]=${i}`).join('');
|
||||
if (filters.genre.value.length)
|
||||
link += filters.genre.value.map(i => `&genre[]=${i}`).join('');
|
||||
|
||||
const result = await fetchApi(link);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const result = await fetchApi(this.site + novelPath);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const styletag = Array.from(
|
||||
loadedCheerio('meta[name=msapplication-TileImage] + style')
|
||||
.html()
|
||||
?.matchAll(/"(.*?)"/g) || [],
|
||||
m => m[1],
|
||||
);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: styletag[0] || loadedCheerio('.entry-title').text() || 'Untitled',
|
||||
author: styletag[1],
|
||||
summary: loadedCheerio('.maininfo span p').text(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
novel.cover =
|
||||
this.coverUriPrefix +
|
||||
loadedCheerio('img.ts-post-image').attr('data-sxrx');
|
||||
|
||||
loadedCheerio('.maininfo li').each(function () {
|
||||
const detailName = loadedCheerio(this).find('b').text().trim();
|
||||
const detail = loadedCheerio(this).find('b').remove().end().text().trim();
|
||||
|
||||
switch (detailName) {
|
||||
case 'Status:':
|
||||
novel.status = detail;
|
||||
break;
|
||||
case 'Genre:':
|
||||
novel.genres = detail;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('.bxcl > ul > li').each((i, el) => {
|
||||
const chapterName = loadedCheerio(el).find('a').text();
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href');
|
||||
|
||||
if (!chapterUrl) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapter.reverse();
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const url = this.site + chapterPath;
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
loadedCheerio('.entry-content div[style="display:none"]').remove();
|
||||
|
||||
const chapterText = loadedCheerio('.entry-content').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = this.site + 'page/' + page + '/?s=' + searchTerm;
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
status: {
|
||||
value: '',
|
||||
label: 'Status',
|
||||
options: [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Ongoing', value: 'Ongoing' },
|
||||
{ label: 'Completed', value: 'Completed' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
sort: {
|
||||
value: 'popular',
|
||||
label: 'Urutkan',
|
||||
options: [
|
||||
{ label: 'A-Z', value: 'title' },
|
||||
{ label: 'Z-A', value: 'titlereverse' },
|
||||
{ label: 'Terbarui', value: 'update' },
|
||||
{ label: 'Ditambahkan', value: 'latest' },
|
||||
{ label: 'Terpopuler', value: 'popular' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
type: {
|
||||
value: [],
|
||||
label: 'Tipe',
|
||||
options: [
|
||||
{ label: 'Chinese Novel', value: 'chinese-novel' },
|
||||
{ label: 'Chinese Web Novel', value: 'chinese-web-novel' },
|
||||
{ label: 'Filipino Novel', value: 'filipino-novel' },
|
||||
{ label: 'Indonesia Novel', value: 'indonesia-novel' },
|
||||
{ label: 'Korean Novel', value: 'korean-novel' },
|
||||
{ label: 'Light Novel', value: 'light-novel' },
|
||||
{ label: 'Light Novel (CN)', value: 'light-novel-cn' },
|
||||
{ label: 'Light Novel (JP)', value: 'light-novel-jp' },
|
||||
{ label: 'Light Novel (KR)', value: 'light-novel-kr' },
|
||||
{ label: 'Malaysian Novel', value: 'malaysian-novel' },
|
||||
{ label: 'Published Novel (CN)', value: 'published-novel-cn' },
|
||||
{ label: 'Published Novel (JP)', value: 'published-novel-jp' },
|
||||
{ label: 'Published Novel (KR)', value: 'published-novel-kr' },
|
||||
{ label: 'Published Novel (TH)', value: 'published-novel-th' },
|
||||
{ label: 'Thai Novel', value: 'thai-novel' },
|
||||
{ label: 'Vietnamese Novel', value: 'vietnamese-novel' },
|
||||
{ label: 'Web Novel', value: 'web-novel' },
|
||||
{ label: 'Webnovel', value: 'webnovel' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
genre: {
|
||||
value: [],
|
||||
label: 'Genres',
|
||||
options: [
|
||||
{ label: 'Action', value: 'action' },
|
||||
{ label: 'Adult', value: 'adult' },
|
||||
{ label: 'Adventure', value: 'adventure' },
|
||||
{ label: 'Celebrity', value: 'celebrity' },
|
||||
{ label: 'Comedy', value: 'comedy' },
|
||||
{ label: 'ction', value: 'ction' },
|
||||
{ label: 'Drama', value: 'drama' },
|
||||
{ label: 'Eastern', value: 'eastern' },
|
||||
{ label: 'Ecchi', value: 'ecchi' },
|
||||
{ label: 'Fantasy', value: 'fantasy' },
|
||||
{ label: 'Game', value: 'game' },
|
||||
{ label: 'Games', value: 'games' },
|
||||
{ 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: 'Life', value: 'life' },
|
||||
{ label: 'LitRPG', value: 'litrpg' },
|
||||
{ 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: 'Psychologic', value: 'psychologic' },
|
||||
{ label: 'Psychological', value: 'psychological' },
|
||||
{ label: 'Recarnation', value: 'recarnation' },
|
||||
{ label: 'Reincarnation', value: 'reincarnation' },
|
||||
{ label: 'Romance', value: 'romance' },
|
||||
{ label: 'School', value: 'school' },
|
||||
{ label: 'School Life', value: 'school-life' },
|
||||
{ label: 'Sci-fi', value: 'sci-fi' },
|
||||
{ label: 'Seinen', value: 'seinen' },
|
||||
{ label: 'Shotacon', value: 'shotacon' },
|
||||
{ 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: 'Tragedy', value: 'tragedy' },
|
||||
{ label: 'Urban', value: 'urban' },
|
||||
{ label: 'Urban Life', value: 'urban-life' },
|
||||
{
|
||||
label: 've names:N/A Genre:Romance',
|
||||
value: 've-namesn-a-genreromance',
|
||||
},
|
||||
{ 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 NovelRingan();
|
||||
@@ -0,0 +1,247 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
|
||||
class SakuraNovel implements Plugin.PluginBase {
|
||||
id = 'sakura.id';
|
||||
name = 'SakuraNovel';
|
||||
icon = 'src/id/sakuranovel/icon.png';
|
||||
site = 'https://sakuranovel.id/';
|
||||
version = '1.0.1';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.flexbox2-item').each((i, el) => {
|
||||
const novelName = loadedCheerio(el)
|
||||
.find('.flexbox2-title span')
|
||||
.first()
|
||||
.text();
|
||||
const novelCover = loadedCheerio(el).find('img').attr('src');
|
||||
const novelUrl = loadedCheerio(el)
|
||||
.find('.flexbox2-content > a')
|
||||
.attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
novels.push({
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
});
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}advanced-search/page/${page}/?title&author&yearx`;
|
||||
link += `&status=${filters.status.value}`;
|
||||
link += `&type=${filters.type.value}`;
|
||||
link += `&order=${filters.sort.value}`;
|
||||
|
||||
if (filters.lang.value.length)
|
||||
link += filters.lang.value.map(i => `&country[]=${i}`).join('');
|
||||
if (filters.genre.value.length)
|
||||
link += filters.genre.value.map(i => `&genre[]=${i}`).join('');
|
||||
|
||||
const result = await fetchApi(link);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const result = await fetchApi(this.site + novelPath);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
loadedCheerio('.series-synops div').remove();
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.series-title h2').text().trim() || 'Untitled',
|
||||
cover: loadedCheerio('.series-thumb img').attr('src'),
|
||||
author: loadedCheerio(".series-infolist > li b:contains('Author') +")
|
||||
.text()
|
||||
.trim(),
|
||||
status: loadedCheerio('.status').text().trim(),
|
||||
summary: loadedCheerio('.series-synops').text().trim(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
novel.genres = loadedCheerio('.series-genres')
|
||||
.children('a')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
const imageTitle = novel.cover
|
||||
?.split('/')
|
||||
.pop()
|
||||
?.split('-')
|
||||
.join(' ')
|
||||
.split('.')[0];
|
||||
const chapterTitle = novel.name
|
||||
.replace(/\(LN\)|\(WN\)/, '')
|
||||
.split(',')[0]
|
||||
.trim();
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('.series-flexright li').each((i, el) => {
|
||||
const chapterName = loadedCheerio(el)
|
||||
.find('a span')
|
||||
.first()
|
||||
.text()
|
||||
.replace(chapterTitle, '')
|
||||
.replace(imageTitle!, '')
|
||||
.replace(/Bahasa Indonesia/, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const releaseDate = loadedCheerio(el)
|
||||
.find('.date')
|
||||
.text()
|
||||
.trim()
|
||||
.split('/')
|
||||
.map(x => Number(x));
|
||||
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href');
|
||||
|
||||
if (!chapterUrl) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
releaseTime: new Date(
|
||||
releaseDate[2],
|
||||
releaseDate[1],
|
||||
releaseDate[0],
|
||||
).toISOString(),
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapter.reverse();
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(this.site + chapterPath);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const divi = loadedCheerio("div:contains('Daftar Isi') +")
|
||||
.find('div:first')
|
||||
.attr('class');
|
||||
loadedCheerio(`.${divi}`).remove();
|
||||
const chapterText =
|
||||
loadedCheerio("div:contains('Daftar Isi') +").html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}page/${page}/?s=${searchTerm}`;
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
status: {
|
||||
value: '',
|
||||
label: 'Status',
|
||||
options: [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Ongoing', value: 'ongoing' },
|
||||
{ label: 'Completed', value: 'completed' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
type: {
|
||||
value: '',
|
||||
label: 'Type',
|
||||
options: [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Web Novel', value: 'Web+Novel' },
|
||||
{ label: 'Light Novel', value: 'Light+Novel' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
sort: {
|
||||
value: 'rating',
|
||||
label: 'Order By',
|
||||
options: [
|
||||
{ label: 'A-Z', value: 'title' },
|
||||
{ label: 'Z-A', value: 'titlereverse' },
|
||||
{ label: 'Latest Update', value: 'update' },
|
||||
{ label: 'Latest Added', value: 'latest' },
|
||||
{ label: 'Popular', value: 'popular' },
|
||||
{ label: 'Rating', value: 'rating' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
lang: {
|
||||
value: ['china', 'jepang', 'korea', 'unknown'],
|
||||
label: 'Country',
|
||||
options: [
|
||||
{ label: 'China', value: 'china' },
|
||||
{ label: 'Jepang', value: 'jepang' },
|
||||
{ label: 'Korea', value: 'korea' },
|
||||
{ label: 'Unknown', value: 'unknown' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
genre: {
|
||||
value: [],
|
||||
label: 'Genres',
|
||||
options: [
|
||||
{ label: 'Action', value: 'action' },
|
||||
{ label: 'Adult', value: 'adult' },
|
||||
{ label: 'Adventure', value: 'adventure' },
|
||||
{ label: 'Comedy', value: 'comedy' },
|
||||
{ label: 'Drama', value: 'drama' },
|
||||
{ label: 'Ecchi', value: 'ecchi' },
|
||||
{ label: 'Fantasy', value: 'fantasy' },
|
||||
{ label: 'Gender Bender', value: 'gender-bender' },
|
||||
{ label: 'Harem', value: 'harem' },
|
||||
{ label: 'Horror', value: 'horror' },
|
||||
{ label: 'Josei', value: 'josei' },
|
||||
{ label: 'Josei', value: 'josei' },
|
||||
{ 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: 'Smut', value: 'smut' },
|
||||
{ label: 'Supernatural', value: 'supernatural' },
|
||||
{ label: 'Tragedy', value: 'tragedy' },
|
||||
{ label: 'Wuxia', value: 'wuxia' },
|
||||
{ label: 'Xianxia', value: 'xianxia' },
|
||||
{ label: 'Xuanhuan', value: 'xuanhuan' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new SakuraNovel();
|
||||
@@ -0,0 +1,349 @@
|
||||
import { load as loadCheerio } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
// const novelStatus = require('@/types/constants');
|
||||
// const isUrlAbsolute = require('@/lib/utils');
|
||||
// const parseDate = require('@libs/parseDate');
|
||||
|
||||
class Syosetu implements Plugin.PluginBase {
|
||||
id = 'yomou.syosetu';
|
||||
name = 'Syosetu';
|
||||
icon = 'src/jp/syosetu/icon.png';
|
||||
site = 'https://yomou.syosetu.com/';
|
||||
novelPrefix = 'https://ncode.syosetu.com';
|
||||
version = '1.1.2';
|
||||
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',
|
||||
};
|
||||
searchUrl = (pagenum?: number, order?: string) => {
|
||||
return `${this.site}search.php?order=${order || 'hyoka'}${
|
||||
pagenum !== undefined
|
||||
? `&p=${pagenum <= 1 || pagenum > 100 ? '1' : pagenum}` // check if pagenum is between 1 and 100
|
||||
: '' // if isn't don't set ?p
|
||||
}`;
|
||||
};
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const getNovelsFromPage = async (pagenumber: number) => {
|
||||
// load page
|
||||
let url = this.site;
|
||||
|
||||
if (!filters.genre.value) {
|
||||
url += `rank/list/type/${filters.ranking.value}_${filters.modifier.value}/?p=${pagenumber}`;
|
||||
} else {
|
||||
url += `rank/${
|
||||
filters.genre.value.length === 1 ? 'isekailist' : 'genrelist'
|
||||
}/type/${filters.ranking.value}_${filters.genre.value}${
|
||||
filters.modifier.value === 'total' ? '' : `_${filters.modifier.value}`
|
||||
}/?p=${pagenumber}`;
|
||||
}
|
||||
const html = await (await fetchApi(url)).text();
|
||||
|
||||
const loadedCheerio = loadCheerio(html, {
|
||||
decodeEntities: false,
|
||||
});
|
||||
|
||||
if (parseInt(loadedCheerio('.is-current').html() || '1') !== pagenumber)
|
||||
return [];
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
loadedCheerio('.c-card').each((_, e) => {
|
||||
const anchor = loadedCheerio(e).find('.p-ranklist-item__title a');
|
||||
const url = anchor.attr('href');
|
||||
if (!url) return;
|
||||
const name = anchor.text();
|
||||
const novel: Plugin.NovelItem = {
|
||||
path: url.replace(this.novelPrefix, ''),
|
||||
name,
|
||||
cover: defaultCover,
|
||||
};
|
||||
novels.push(novel);
|
||||
});
|
||||
return novels;
|
||||
};
|
||||
const novels = await getNovelsFromPage(pageNo);
|
||||
return novels;
|
||||
}
|
||||
private async parseChaptersFromPage(
|
||||
loadedCheerio: cheerio.CheerioAPI,
|
||||
): Promise<Plugin.ChapterItem[]> {
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('.p-eplist__sublist').each((_, element) => {
|
||||
const chapterLink = loadedCheerio(element).find('a');
|
||||
const chapterUrl = chapterLink.attr('href');
|
||||
const chapterName = chapterLink.text().trim();
|
||||
const releaseDate = loadedCheerio(element)
|
||||
.find('.p-eplist__update')
|
||||
.text()
|
||||
.trim()
|
||||
.split(' ')[0]
|
||||
.replace(/\//g, '-');
|
||||
|
||||
if (chapterUrl) {
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
releaseTime: releaseDate,
|
||||
path: chapterUrl.replace(this.novelPrefix, ''),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return chapters;
|
||||
}
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
// First fetch main page
|
||||
const result = await fetchApi(this.novelPrefix + novelPath, {
|
||||
headers: this.headers,
|
||||
});
|
||||
const body = await result.text();
|
||||
const loadedCheerio = loadCheerio(body, { decodeEntities: false });
|
||||
|
||||
// Parse status
|
||||
let status = 'Unknown';
|
||||
if (
|
||||
loadedCheerio('.c-announce').text().includes('連載中') ||
|
||||
loadedCheerio('.c-announce').text().includes('未完結')
|
||||
) {
|
||||
status = NovelStatus.Ongoing;
|
||||
} else if (
|
||||
loadedCheerio('.c-announce').text().includes('更新されていません')
|
||||
) {
|
||||
status = NovelStatus.OnHiatus;
|
||||
} else if (loadedCheerio('.c-announce').text().includes('完結')) {
|
||||
status = NovelStatus.Completed;
|
||||
}
|
||||
|
||||
// Create novel object with metadata
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.p-novel__title').text(),
|
||||
author: loadedCheerio('.p-novel__author')
|
||||
.text()
|
||||
.replace('作者:', '')
|
||||
.trim(),
|
||||
status: status,
|
||||
artist: '',
|
||||
cover: defaultCover,
|
||||
chapters: [],
|
||||
genres: loadedCheerio('meta[property="og:description"]')
|
||||
.attr('content')
|
||||
?.split(' ')
|
||||
.join(','), // Get genres from meta tag
|
||||
};
|
||||
|
||||
// Get summary if available
|
||||
novel.summary = loadedCheerio('#novel_ex').html() || '';
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
// Get last page URL first
|
||||
const lastPageLink = loadedCheerio('.c-pager__item--last').attr('href');
|
||||
|
||||
if (!lastPageLink) {
|
||||
// If no pagination, just parse chapters from the current page
|
||||
loadedCheerio('.p-eplist__sublist').each((_, element) => {
|
||||
const chapterLink = loadedCheerio(element).find('a');
|
||||
const chapterUrl = chapterLink.attr('href');
|
||||
const chapterName = chapterLink.text().trim();
|
||||
const releaseDate = loadedCheerio(element)
|
||||
.find('.p-eplist__update')
|
||||
.text()
|
||||
.trim()
|
||||
.split(' ')[0]
|
||||
.replace(/\//g, '-');
|
||||
|
||||
if (chapterUrl) {
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
releaseTime: releaseDate,
|
||||
path: chapterUrl.replace(this.novelPrefix, ''),
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const lastPageMatch = lastPageLink.match(/\?p=(\d+)/);
|
||||
const totalPages = lastPageMatch ? parseInt(lastPageMatch[1]) : 1;
|
||||
|
||||
// Fetch all pages in parallel for better performance
|
||||
const pagePromises = Array.from({ length: totalPages }, (_, i) =>
|
||||
fetchApi(`${this.novelPrefix}${novelPath}?p=${i + 1}`).then(r =>
|
||||
r.text(),
|
||||
),
|
||||
);
|
||||
|
||||
const pageResults = await Promise.all(pagePromises);
|
||||
|
||||
// Process each page's chapters
|
||||
pageResults.forEach(pageBody => {
|
||||
const pageCheerio = loadCheerio(pageBody, { decodeEntities: false });
|
||||
pageCheerio('.p-eplist__sublist').each((_, element) => {
|
||||
const chapterLink = pageCheerio(element).find('a');
|
||||
const chapterUrl = chapterLink.attr('href');
|
||||
const chapterName = chapterLink.text().trim();
|
||||
const releaseDate = pageCheerio(element)
|
||||
.find('.p-eplist__update')
|
||||
.text()
|
||||
.trim()
|
||||
.split(' ')[0]
|
||||
.replace(/\//g, '-');
|
||||
|
||||
if (chapterUrl) {
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
releaseTime: releaseDate,
|
||||
path: chapterUrl.replace(this.novelPrefix, ''),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(this.novelPrefix + chapterPath, {
|
||||
headers: this.headers,
|
||||
});
|
||||
const body = await result.text();
|
||||
|
||||
const cheerioQuery = loadCheerio(body, {
|
||||
decodeEntities: false,
|
||||
});
|
||||
|
||||
// Get the chapter title
|
||||
const chapterTitle = cheerioQuery('.p-novel__title').html() || '';
|
||||
|
||||
// Get the chapter content
|
||||
const chapterContent =
|
||||
cheerioQuery(
|
||||
'.p-novel__body .p-novel__text:not([class*="p-novel__text--"])',
|
||||
).html() || '';
|
||||
|
||||
// Combine title and content with proper HTML structure
|
||||
return `<h1>${chapterTitle}</h1>${chapterContent}`;
|
||||
}
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let novels = [];
|
||||
|
||||
// returns list of novels from given page
|
||||
const getNovelsFromPage = async (pagenumber: number) => {
|
||||
// load page
|
||||
const url = this.searchUrl(pagenumber) + `&word=${searchTerm}`;
|
||||
const result = await fetchApi(url, { headers: this.headers });
|
||||
const body = await result.text();
|
||||
// Cheerio it!
|
||||
const cheerioQuery = loadCheerio(body, { decodeEntities: false });
|
||||
|
||||
const pageNovels: Plugin.NovelItem[] = [];
|
||||
// find class=searchkekka_box
|
||||
cheerioQuery('.searchkekka_box').each((i, e) => {
|
||||
// get div with link and name
|
||||
const novelDIV = cheerioQuery(e).find('.novel_h');
|
||||
// get link element
|
||||
const novelA = novelDIV.children()[0];
|
||||
// add new novel to array
|
||||
const novelPath = novelA.attribs.href.replace(this.novelPrefix, '');
|
||||
if (novelPath) {
|
||||
pageNovels.push({
|
||||
name: novelDIV.text(), // get the name
|
||||
path: novelPath, // get last part of the link
|
||||
cover: defaultCover,
|
||||
});
|
||||
}
|
||||
});
|
||||
// return all novels from this page
|
||||
return pageNovels;
|
||||
};
|
||||
|
||||
// counter of loaded pages
|
||||
// let pagesLoaded = 0;
|
||||
// do {
|
||||
// // always load first one
|
||||
// novels.push(...(await getNovelsFromPage(pagesLoaded + 1)));
|
||||
// pagesLoaded++;
|
||||
// } while (pagesLoaded < maxPageLoad && isNext); // check if we should load more
|
||||
|
||||
novels = await getNovelsFromPage(pageNo);
|
||||
|
||||
/** Use
|
||||
* novels.push(...(await getNovelsFromPage(pageNumber)))
|
||||
* if you want to load more
|
||||
*/
|
||||
|
||||
// respond with novels!
|
||||
return novels;
|
||||
}
|
||||
|
||||
resolveUrl(path: string): string {
|
||||
return this.novelPrefix + path;
|
||||
}
|
||||
filters = {
|
||||
ranking: {
|
||||
type: FilterTypes.Picker,
|
||||
label: 'Ranked by',
|
||||
options: [
|
||||
{ label: '日間', value: 'daily' },
|
||||
{ label: '週間', value: 'weekly' },
|
||||
{ label: '月間', value: 'monthly' },
|
||||
{ label: '四半期', value: 'quarter' },
|
||||
{ label: '年間', value: 'yearly' },
|
||||
{ label: '累計', value: 'total' },
|
||||
],
|
||||
value: 'total',
|
||||
},
|
||||
genre: {
|
||||
type: FilterTypes.Picker,
|
||||
label: 'Ranking Genre',
|
||||
options: [
|
||||
{ label: '総ジャンル', value: '' },
|
||||
{ label: '異世界転生/転移〔恋愛〕〕', value: '1' },
|
||||
{ label: '異世界転生/転移〔ファンタジー〕', value: '2' },
|
||||
{ label: '異世界転生/転移〔文芸・SF・その他〕', value: 'o' },
|
||||
{ label: '異世界〔恋愛〕', value: '101' },
|
||||
{ label: '現実世界〔恋愛〕', value: '102' },
|
||||
{ label: 'ハイファンタジー〔ファンタジー〕', value: '201' },
|
||||
{ label: 'ローファンタジー〔ファンタジー〕', value: '202' },
|
||||
{ label: '純文学〔文芸〕', value: '301' },
|
||||
{ label: 'ヒューマンドラマ〔文芸〕', value: '302' },
|
||||
{ label: '歴史〔文芸〕', value: '303' },
|
||||
{ label: '推理〔文芸〕', value: '304' },
|
||||
{ label: 'ホラー〔文芸〕', value: '305' },
|
||||
{ label: 'アクション〔文芸〕', value: '306' },
|
||||
{ label: 'コメディー〔文芸〕', value: '307' },
|
||||
{ label: 'VRゲーム〔SF〕', value: '401' },
|
||||
{ label: '宇宙〔SF〕', value: '402' },
|
||||
{ label: '空想科学〔SF〕', value: '403' },
|
||||
{ label: 'パニック〔SF〕', value: '404' },
|
||||
{ label: '童話〔その他〕', value: '9901' },
|
||||
{ label: '詩〔その他〕', value: '9902' },
|
||||
{ label: 'エッセイ〔その他〕', value: '9903' },
|
||||
{ label: 'その他〔その他〕', value: '9999' },
|
||||
],
|
||||
value: '',
|
||||
},
|
||||
modifier: {
|
||||
type: FilterTypes.Picker,
|
||||
label: 'Modifier',
|
||||
options: [
|
||||
{ label: 'すべて', value: 'total' },
|
||||
{ label: '連載中', value: 'r' },
|
||||
{ label: '完結済', value: 'er' },
|
||||
{ label: '短編', value: 't' },
|
||||
],
|
||||
value: 'total',
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new Syosetu();
|
||||
@@ -0,0 +1,277 @@
|
||||
import { fetchText } from '@/lib/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { load as loadCheerio } from 'cheerio';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class KakuyomuPlugin implements Plugin.PluginBase {
|
||||
id = 'kakuyomu';
|
||||
name = 'kakuyomu';
|
||||
icon = 'src/jp/kakuyomu/icon.png';
|
||||
site = 'https://kakuyomu.jp';
|
||||
version = '1.0.0';
|
||||
filters = {
|
||||
genre: {
|
||||
type: FilterTypes.Picker,
|
||||
label: 'Genre',
|
||||
options: [
|
||||
{ label: '総合', value: 'all' },
|
||||
{ label: '異世界ファンタジー', value: 'fantasy' },
|
||||
{ label: '現代ファンタジー', value: 'action' },
|
||||
{ label: 'SF', value: 'sf' },
|
||||
{ label: '恋愛', value: 'love_story' },
|
||||
{ label: 'ラブコメ', value: 'romance' },
|
||||
{ label: '現代ドラマ', value: 'drama' },
|
||||
{ label: 'ホラー', value: 'horror' },
|
||||
{ label: 'ミステリー', value: 'mystery' },
|
||||
{ label: 'エッセイ・ノンフィクション', value: 'nonfiction' },
|
||||
{ label: '歴史・時代・伝奇', value: 'history' },
|
||||
{ label: '創作論・評論', value: 'criticism' },
|
||||
{ label: '詩・童話・その他', value: 'others' },
|
||||
],
|
||||
value: 'all',
|
||||
},
|
||||
period: {
|
||||
type: FilterTypes.Picker,
|
||||
label: 'Period',
|
||||
options: [
|
||||
{ label: '累計', value: 'entire' },
|
||||
{ label: '日間', value: 'daily' },
|
||||
{ label: '週間', value: 'weekly' },
|
||||
{ label: '月間', value: 'monthly' },
|
||||
{ label: '年間', value: 'yearly' },
|
||||
],
|
||||
value: 'entire',
|
||||
},
|
||||
} satisfies Filters;
|
||||
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
|
||||
|
||||
//flag indicates whether access to LocalStorage, SesesionStorage is required.
|
||||
webStorageUtilized?: boolean;
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = new URL(
|
||||
`/rankings/${filters.genre.value}/${filters.period.value}`,
|
||||
this.site,
|
||||
);
|
||||
if (pageNo > 1) {
|
||||
url.searchParams.set('page', pageNo.toString());
|
||||
}
|
||||
const html = await fetchText(url.toString());
|
||||
const $ = loadCheerio(html);
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
$('.widget-media-genresWorkList-right > .widget-work').each((_, elem) => {
|
||||
const anchor = $(elem).find('a.widget-workCard-titleLabel');
|
||||
const path = anchor.attr('href');
|
||||
if (!path) return;
|
||||
const name = anchor.text();
|
||||
novels.push({
|
||||
name,
|
||||
path,
|
||||
cover: defaultCover,
|
||||
});
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const url = new URL(novelPath, this.site);
|
||||
const html = await fetchText(url.toString());
|
||||
const $ = loadCheerio(html);
|
||||
|
||||
const json = JSON.parse(
|
||||
$('script#__NEXT_DATA__[type="application/json"]').html() || '{}',
|
||||
);
|
||||
const apolloState = json?.props?.pageProps?.__APOLLO_STATE__ ?? {};
|
||||
|
||||
const work = Object.values(apolloState).find(
|
||||
v =>
|
||||
typeof v === 'object' &&
|
||||
v !== null &&
|
||||
'__typename' in v &&
|
||||
v.__typename === 'Work' &&
|
||||
'id' in v &&
|
||||
v.id === novelPath.replace('/works/', ''),
|
||||
) as Work;
|
||||
|
||||
const author = Object.values(apolloState).find(
|
||||
v =>
|
||||
typeof v === 'object' &&
|
||||
v !== null &&
|
||||
'__typename' in v &&
|
||||
v.__typename === 'UserAccount' &&
|
||||
'id' in v &&
|
||||
v.id === work.author.__ref.replace('UserAccount:', ''),
|
||||
) as UserAccount;
|
||||
|
||||
const chapters = Object.values(apolloState).filter(v => {
|
||||
if (
|
||||
typeof v === 'object' &&
|
||||
v !== null &&
|
||||
'__typename' in v &&
|
||||
v.__typename === 'Chapter'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}) as Chapter[];
|
||||
|
||||
const tableOfContentsChapter = Object.values(apolloState).filter(v => {
|
||||
if (
|
||||
typeof v === 'object' &&
|
||||
v !== null &&
|
||||
'__typename' in v &&
|
||||
v.__typename === 'TableOfContentsChapter'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}) as TableOfContentsChapter[];
|
||||
|
||||
const episodes = Object.values(apolloState).filter(v => {
|
||||
if (
|
||||
typeof v === 'object' &&
|
||||
v !== null &&
|
||||
'__typename' in v &&
|
||||
v.__typename === 'Episode'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}) as Episode[];
|
||||
|
||||
const joinChapters: {
|
||||
chapter: Chapter | undefined;
|
||||
episode: Episode;
|
||||
}[] = episodes.map(v => {
|
||||
const chapter = tableOfContentsChapter.find(c =>
|
||||
c.episodeUnions.some(e => e.__ref === `Episode:${v.id}`),
|
||||
)?.chapter;
|
||||
return {
|
||||
chapter: chapters.find(
|
||||
c => c.id === chapter?.__ref.replace('Chapter:', ''),
|
||||
),
|
||||
episode: v,
|
||||
};
|
||||
});
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: work.title,
|
||||
cover: work.adminCoverImageUrl ?? defaultCover,
|
||||
genres: (work?.tagLabels ?? []).join(','),
|
||||
author: author?.activityName,
|
||||
status:
|
||||
work.serialStatus === 'COMPLETED'
|
||||
? NovelStatus.Completed
|
||||
: NovelStatus.Ongoing,
|
||||
summary: work.introduction,
|
||||
chapters: joinChapters.map(v => {
|
||||
return {
|
||||
name: v.chapter?.title
|
||||
? `${v.chapter?.title} - ${v.episode?.title}`
|
||||
: v.episode?.title ?? '',
|
||||
path: `${novelPath}/episodes/${v.episode?.id}`,
|
||||
releaseTime: new Date(v.episode?.publishedAt ?? 0).toISOString(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const url = new URL(chapterPath, this.site);
|
||||
const html = await fetchText(url.toString());
|
||||
const $ = loadCheerio(html);
|
||||
const chapterTitle = $('.chapterTitle').text() ?? '';
|
||||
const episodeTitle = $('.widget-episodeTitle').html() ?? '';
|
||||
const episodeBody = $('.widget-episodeBody').html() ?? '';
|
||||
const chapterText = `
|
||||
<div>
|
||||
${chapterTitle ? `<h1>${chapterTitle}</h1>` : ''}
|
||||
<h2>${episodeTitle}</h2>
|
||||
</div>
|
||||
<p><br><br></p>
|
||||
${episodeBody}`;
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = new URL('/search', this.site);
|
||||
url.searchParams.set('q', searchTerm);
|
||||
if (pageNo > 1) {
|
||||
url.searchParams.set('page', pageNo.toString());
|
||||
}
|
||||
const html = await fetchText(url.toString());
|
||||
const $ = loadCheerio(html);
|
||||
|
||||
const json = JSON.parse(
|
||||
$('script#__NEXT_DATA__[type="application/json"]').html() || '{}',
|
||||
);
|
||||
const works = Object.values(
|
||||
json?.props?.pageProps?.__APOLLO_STATE__ ?? {},
|
||||
).filter(v => {
|
||||
if (
|
||||
typeof v === 'object' &&
|
||||
v !== null &&
|
||||
'__typename' in v &&
|
||||
v.__typename === 'Work'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}) as Work[];
|
||||
|
||||
const novels: Plugin.NovelItem[] = works.map(v => ({
|
||||
name: v.title ?? '',
|
||||
path: `/works/${v.id}`,
|
||||
cover: v.adminCoverImageUrl ?? defaultCover,
|
||||
}));
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new KakuyomuPlugin();
|
||||
|
||||
type Work = {
|
||||
id: string;
|
||||
title: string;
|
||||
serialStatus: string;
|
||||
tagLabels: string[];
|
||||
introduction: string;
|
||||
adminCoverImageUrl?: string;
|
||||
author: {
|
||||
__ref: string;
|
||||
};
|
||||
};
|
||||
|
||||
type UserAccount = {
|
||||
activityName: string;
|
||||
};
|
||||
|
||||
type Chapter = {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type Episode = {
|
||||
title: string;
|
||||
id: string;
|
||||
publishedAt: string;
|
||||
};
|
||||
|
||||
type TableOfContentsChapter = {
|
||||
chapter: {
|
||||
__ref: string;
|
||||
};
|
||||
episodeUnions: {
|
||||
__ref: string;
|
||||
}[];
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
|
||||
class Agitoon implements Plugin.PluginBase {
|
||||
id = 'agit.xyz';
|
||||
name = 'Agitoon';
|
||||
icon = 'src/kr/agitoon/icon.png';
|
||||
site = 'https://agit664.xyz';
|
||||
version = '3.1.0';
|
||||
static url: string | undefined;
|
||||
|
||||
async checkUrl() {
|
||||
if (!Agitoon.url) {
|
||||
const res = await fetchApi(this.site);
|
||||
if (!res.ok) Agitoon.url = this.site;
|
||||
else Agitoon.url = res.url.replace(/\/$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ showLatestNovels }: Plugin.PopularNovelsOptions,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
await this.checkUrl();
|
||||
const res = await fetchApi(Agitoon.url + '/novel/index.update.php', {
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
},
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
mode: 'get_data_novel_list_p',
|
||||
novel_menu: showLatestNovels ? '1' : '3',
|
||||
np_day: new Date().getDay(),
|
||||
np_rank: '1',
|
||||
np_distributor: '0',
|
||||
np_genre: '00',
|
||||
np_order: '1',
|
||||
np_genre_ex_1: '00',
|
||||
np_genre_ex_2: '00',
|
||||
list_limit: 20 * (pageNo - 1),
|
||||
is_query_first: pageNo == 1,
|
||||
}).toString(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Failed to get popular novels: (${res.status}: ${res.statusText}) (try to open in webview)`,
|
||||
);
|
||||
}
|
||||
const resJson = (await res.json()) as response;
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
resJson?.list?.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.wr_subject,
|
||||
cover: Agitoon.url + novel.np_dir + '/thumbnail/' + novel.np_thumbnail,
|
||||
path: novel.wr_id,
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
await this.checkUrl();
|
||||
const result = await fetchApi(this.resolveUrl(novelPath, true)).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
const loadedCheerio = parseHTML(result, { decodeEntities: false });
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h5.pt-2').text(),
|
||||
cover: loadedCheerio('div.col-5.pr-0.pl-0 img').attr('src'),
|
||||
summary: loadedCheerio('.pt-1.mt-1.pb-1.mb-1').text(),
|
||||
};
|
||||
|
||||
novel.author = loadedCheerio('.post-item-list-cate-v')
|
||||
.first()
|
||||
.text()
|
||||
.split(' : ')[1];
|
||||
|
||||
const genres = loadedCheerio('.col-7 > .post-item-list-cate > span')
|
||||
.map((index, element) => loadedCheerio(element).text().trim())
|
||||
.get();
|
||||
|
||||
if (genres.length) {
|
||||
novel.genres = genres.join(', ');
|
||||
}
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
const res = await fetchApi(Agitoon.url + '/novel/list.update.php', {
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
},
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
mode: 'get_data_novel_list_c',
|
||||
wr_id_p: novelPath,
|
||||
page_no: '1',
|
||||
cnt_list: '10000',
|
||||
order_type: 'Asc',
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
const resJson = (await res.json()) as responseBook;
|
||||
resJson?.list?.forEach(chapter =>
|
||||
chapters.push({
|
||||
name: chapter.wr_subject,
|
||||
path: chapter.wr_id + '/2',
|
||||
releaseTime: chapter.wr_datetime,
|
||||
}),
|
||||
);
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
await this.checkUrl();
|
||||
const result = await fetchApi(this.resolveUrl(chapterPath)).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
|
||||
const loadedCheerio = parseHTML(result);
|
||||
let content = loadedCheerio('#id_wr_content').html() || '';
|
||||
|
||||
// gets rid of the popup thingy
|
||||
content = content
|
||||
.replace('팝업메뉴는 빈공간을 더치하거나 스크룰시 사라집니다', '')
|
||||
.trim();
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
if (pageNo !== 1) return [];
|
||||
await this.checkUrl();
|
||||
const rawResults = await fetchApi(Agitoon.url + '/novel/search.php', {
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
},
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
mode: 'get_data_novel_list_p_sch',
|
||||
search_novel: searchTerm,
|
||||
list_limit: 0,
|
||||
}).toString(),
|
||||
});
|
||||
const resJson = (await rawResults.json()) as response;
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
resJson?.list?.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.wr_subject,
|
||||
cover:
|
||||
Agitoon.url + '/' + novel.np_dir + '/thumbnail/' + novel.np_thumbnail,
|
||||
path: novel.wr_id,
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
resolveUrl(path: string, isNovel?: boolean) {
|
||||
if (!Agitoon.url)
|
||||
fetchApi(this.site).then(
|
||||
res => (Agitoon.url = res.url.replace(/\/$/, '')),
|
||||
);
|
||||
return (
|
||||
(Agitoon.url ? Agitoon.url : this.site) +
|
||||
(isNovel ? '/novel/list/' : '/novel/view/') +
|
||||
path
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Agitoon();
|
||||
|
||||
type response = {
|
||||
list_limit: number;
|
||||
list?: ListEntity[] | null;
|
||||
list_count: number;
|
||||
};
|
||||
type ListEntity = {
|
||||
wr_id: string;
|
||||
wr_subject: string;
|
||||
np_dir: string;
|
||||
np_type_02: string;
|
||||
np_thumbnail: string;
|
||||
np_author: string;
|
||||
wr_subject2: string;
|
||||
wr_datetime: string;
|
||||
np_distributor: string;
|
||||
np_genre: string;
|
||||
np_country: string;
|
||||
np_age: string;
|
||||
is_scrap: number;
|
||||
};
|
||||
|
||||
type responseBook = {
|
||||
list?: ListEntity2[] | null;
|
||||
download_time: string;
|
||||
};
|
||||
type ListEntity2 = {
|
||||
wr_id: string;
|
||||
wr_subject: string;
|
||||
wr_datetime: string;
|
||||
url_view1: string;
|
||||
novel_c_style1: string;
|
||||
novel_c_str1: string;
|
||||
data_novel_c_view: string;
|
||||
};
|
||||
@@ -0,0 +1,277 @@
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { storage } from '@/lib/storage';
|
||||
|
||||
class KomgaPlugin implements Plugin.PluginBase {
|
||||
id = 'komga';
|
||||
name = 'Komga';
|
||||
icon = 'src/multi/komga/icon.png';
|
||||
version = '1.0.1';
|
||||
|
||||
site = storage.get('url');
|
||||
email = storage.get('email');
|
||||
password = storage.get('password');
|
||||
|
||||
async makeRequest(url: string): Promise<string> {
|
||||
return await fetchApi(url, {
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json;charset=utf-8',
|
||||
'Authorization': `Basic ${this.btoa(this.email + ':' + this.password)}`,
|
||||
},
|
||||
Referer: this.site,
|
||||
}).then(res => res.text());
|
||||
}
|
||||
|
||||
btoa(input = '') {
|
||||
const chars =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
|
||||
const str = input;
|
||||
let output = '';
|
||||
|
||||
for (
|
||||
let block = 0, charCode, i = 0, map = chars;
|
||||
str.charAt(i | 0) || ((map = '='), i % 1);
|
||||
output += map.charAt(63 & (block >> (8 - (i % 1) * 8)))
|
||||
) {
|
||||
charCode = str.charCodeAt((i += 3 / 4));
|
||||
|
||||
if (charCode > 0xff) {
|
||||
throw new Error(
|
||||
"'btoa' failed: The string to be encoded contains characters outside of the Latin1 range.",
|
||||
);
|
||||
}
|
||||
|
||||
block = (block << 8) | charCode;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
flattenArray(arr: any) {
|
||||
return arr.reduce((acc: any, obj: any) => {
|
||||
const { children, ...rest } = obj;
|
||||
acc.push(rest);
|
||||
|
||||
if (children) {
|
||||
acc.push(...this.flattenArray(children));
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
async getSeries(url: string): Promise<Plugin.NovelItem[]> {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
const response = await this.makeRequest(url);
|
||||
|
||||
const series = JSON.parse(response).content;
|
||||
|
||||
for (const s of series) {
|
||||
novels.push({
|
||||
name: s.name,
|
||||
path: 'api/v1/series/' + s.id,
|
||||
cover: this.site + `api/v1/series/${s.id}/thumbnail`,
|
||||
});
|
||||
}
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const read_status = filters?.read_status.value
|
||||
? '&read_status=' + filters?.read_status.value
|
||||
: '';
|
||||
const status = filters?.status.value
|
||||
? '&status=' + filters?.status.value
|
||||
: '';
|
||||
const sort = showLatestNovels ? 'lastModified,desc' : 'name,asc';
|
||||
|
||||
const url = `${this.site}api/v1/series?page=${pageNo - 1}${read_status}${status}&sort=${sort}`;
|
||||
|
||||
return await this.getSeries(url);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: 'Untitled',
|
||||
};
|
||||
|
||||
const url = this.site + novelPath;
|
||||
|
||||
const response = await this.makeRequest(url);
|
||||
|
||||
const series = JSON.parse(response);
|
||||
|
||||
novel.name = series.name;
|
||||
novel.author = series.booksMetadata.authors
|
||||
.filter((author: any) => author.role === 'writer')
|
||||
.reduce(
|
||||
(accumulated: string, current: any) =>
|
||||
accumulated + (accumulated !== '' ? ', ' : '') + current.name,
|
||||
'',
|
||||
);
|
||||
novel.cover = this.site + `api/v1/series/${series.id}/thumbnail`;
|
||||
novel.genres = series.metadata.genres.join(', ');
|
||||
|
||||
switch (series.metadata.status) {
|
||||
case 'ENDED':
|
||||
novel.status = NovelStatus.Completed;
|
||||
break;
|
||||
case 'ONGOING':
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
break;
|
||||
case 'ABANDONED':
|
||||
novel.status = NovelStatus.Cancelled;
|
||||
break;
|
||||
case 'HIATUS':
|
||||
novel.status = NovelStatus.OnHiatus;
|
||||
break;
|
||||
default:
|
||||
novel.status = NovelStatus.Unknown;
|
||||
}
|
||||
|
||||
novel.summary = series.booksMetadata.summary;
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
const booksResponse = await this.makeRequest(
|
||||
this.site + `api/v1/series/${series.id}/books?unpaged=true`,
|
||||
);
|
||||
|
||||
const booksData = JSON.parse(booksResponse).content;
|
||||
|
||||
for (const book of booksData) {
|
||||
const bookManifestResponse = await this.makeRequest(
|
||||
this.site + `opds/v2/books/${book.id}/manifest`,
|
||||
);
|
||||
|
||||
const bookManifest = JSON.parse(bookManifestResponse);
|
||||
|
||||
const toc = this.flattenArray(bookManifest.toc);
|
||||
|
||||
let i = 1;
|
||||
for (const page of bookManifest.readingOrder) {
|
||||
const tocItem = toc.find(
|
||||
(v: any) => v.href?.split('#')[0] === page.href,
|
||||
);
|
||||
const title = tocItem ? tocItem.title : null;
|
||||
chapters.push({
|
||||
name: `${i}/${bookManifest.readingOrder.length} - ${book.metadata.title}${title ? ' - ' + title : ''}`,
|
||||
path: 'opds/v2' + page.href?.split('opds/v2').pop(),
|
||||
});
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const chapterText = await this.makeRequest(this.site + chapterPath);
|
||||
return this.addUrlToImageHref(
|
||||
chapterText,
|
||||
this.site + chapterPath.split('/').slice(0, -1).join('/') + '/',
|
||||
);
|
||||
}
|
||||
|
||||
// Convert images to <img> tag and correct url
|
||||
addUrlToImageHref(htmlString: string, baseUrl: string): string {
|
||||
const $ = parseHTML(htmlString, { xmlMode: true });
|
||||
|
||||
// Convert SVG <image> elements to <img> and add baseUrl if necessary
|
||||
$('svg image').each((_, image) => {
|
||||
const href = $(image).attr('href') || $(image).attr('xlink:href');
|
||||
const width = $(image).attr('width');
|
||||
const height = $(image).attr('height');
|
||||
|
||||
if (href) {
|
||||
const img = $('<img />').attr({
|
||||
src: href.startsWith('http') ? href : `${baseUrl}${href}`,
|
||||
width: width || undefined,
|
||||
height: height || undefined,
|
||||
});
|
||||
$(image).closest('svg').replaceWith(img);
|
||||
}
|
||||
});
|
||||
|
||||
// Update <img> elements to include the base URL if their src is relative
|
||||
$('img').each((_, img) => {
|
||||
const src = $(img).attr('src');
|
||||
if (src && !src.startsWith('http')) {
|
||||
$(img).attr('src', `${baseUrl}${src}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Replace <a> tags with the text inside so its not blue
|
||||
$('a').each((_, a) => {
|
||||
$(a).replaceWith($(a).text());
|
||||
});
|
||||
|
||||
return $.xml();
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}api/v1/series?search=${searchTerm}&page=${pageNo - 1}`;
|
||||
|
||||
return await this.getSeries(url);
|
||||
}
|
||||
|
||||
filters = {
|
||||
status: {
|
||||
value: '',
|
||||
label: 'Status',
|
||||
options: [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Completed', value: NovelStatus.Completed },
|
||||
{ label: 'Ongoing', value: NovelStatus.Ongoing },
|
||||
{ label: 'Cancelled', value: NovelStatus.Cancelled },
|
||||
{ label: 'OnHiatus', value: NovelStatus.OnHiatus },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
read_status: {
|
||||
value: '',
|
||||
label: 'Read status',
|
||||
options: [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Unread', value: 'UNREAD' },
|
||||
{ label: 'Read', value: 'READ' },
|
||||
{ label: 'In progress', value: 'IN_PROGRESS' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
|
||||
pluginSettings = {
|
||||
email: {
|
||||
value: '',
|
||||
label: 'Email',
|
||||
type: 'Text',
|
||||
},
|
||||
password: {
|
||||
value: '',
|
||||
label: 'Password',
|
||||
},
|
||||
url: {
|
||||
value: '',
|
||||
label: 'URL',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default new KomgaPlugin();
|
||||
@@ -0,0 +1,33 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const folder = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
import list from './sources.json' with { type: 'json' };
|
||||
|
||||
export const generateAll = function () {
|
||||
return list.map(source => {
|
||||
console.log(
|
||||
`[fictioneer] Generating: ${source.id}${' '.repeat(20 - source.id.length)}`,
|
||||
);
|
||||
return generator(source);
|
||||
});
|
||||
};
|
||||
|
||||
const generator = function generator(source) {
|
||||
const readNovelFullTemplate = readFileSync(join(folder, 'template.ts'), {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
const pluginScript = `
|
||||
${readNovelFullTemplate}
|
||||
const plugin = new FictioneerPlugin(${JSON.stringify(source)});
|
||||
export default plugin;
|
||||
`.trim();
|
||||
return {
|
||||
lang: source.options?.lang || 'English',
|
||||
filename: source.sourceName,
|
||||
pluginScript,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
[
|
||||
{
|
||||
"id": "daoistquest",
|
||||
"sourceSite": "https://daoist.quest",
|
||||
"sourceName": "Daoist Quest",
|
||||
"options": {
|
||||
"browsePage": "collection/novels",
|
||||
"versionIncrements": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "novelib",
|
||||
"sourceSite": "https://novelib.com",
|
||||
"sourceName": "NovelLib",
|
||||
"options": {
|
||||
"browsePage": "browse"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lilyonthevalley",
|
||||
"sourceSite": "https://lilyonthevalley.com",
|
||||
"sourceName": "Lily on the Valley",
|
||||
"options": {
|
||||
"browsePage": "stories"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "penguinsquad",
|
||||
"sourceSite": "https://penguin-squad.com",
|
||||
"sourceName": "Penguin Squad",
|
||||
"options": {
|
||||
"browsePage": "novels"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "prizmatranslation",
|
||||
"sourceSite": "https://prizmatranslation.com",
|
||||
"sourceName": "Prizma",
|
||||
"options": {
|
||||
"browsePage": "home/all-novels"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "dearestrosalie",
|
||||
"sourceSite": "https://dearestrosalie.com",
|
||||
"sourceName": "Dearest Rosalie",
|
||||
"options": {
|
||||
"browsePage": "stories"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,171 @@
|
||||
import { CheerioAPI, load as loadCheerio, load } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
|
||||
type FictioneerOptions = {
|
||||
browsePage: string;
|
||||
lang?: string;
|
||||
versionIncrements?: number;
|
||||
};
|
||||
|
||||
export type FictioneerMetadata = {
|
||||
id: string;
|
||||
sourceSite: string;
|
||||
sourceName: string;
|
||||
options: FictioneerOptions;
|
||||
};
|
||||
|
||||
class FictioneerPlugin implements Plugin.PluginBase {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
site: string;
|
||||
version: string;
|
||||
options: FictioneerOptions;
|
||||
filters: Filters | undefined = undefined;
|
||||
|
||||
constructor(metadata: FictioneerMetadata) {
|
||||
this.id = metadata.id;
|
||||
this.name = metadata.sourceName;
|
||||
this.icon = `multisrc/fictioneer/${metadata.id.toLowerCase()}/icon.png`;
|
||||
this.site = metadata.sourceSite;
|
||||
const versionIncrements = metadata.options?.versionIncrements || 0;
|
||||
this.version = `1.0.${0 + versionIncrements}`;
|
||||
this.options = metadata.options;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const req = await fetchApi(
|
||||
this.site +
|
||||
'/' +
|
||||
this.options.browsePage +
|
||||
'/' +
|
||||
(pageNo === 1 ? '' : 'page/' + pageNo + '/'),
|
||||
);
|
||||
const body = await req.text();
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
|
||||
return loadedCheerio(
|
||||
'#featured-list > li > div > div, #list-of-stories > li > div > div',
|
||||
)
|
||||
.map((i, el) => {
|
||||
const novelName = loadedCheerio(el).find('h3 > a').text();
|
||||
const novelCover = loadedCheerio(el)
|
||||
.find('a.cell-img:has(img)')
|
||||
.attr('href');
|
||||
const novelUrl = loadedCheerio(el).find('h3 > a').attr('href');
|
||||
|
||||
return {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl!.replace(this.site + '/', '').replace(/\/$/, ''),
|
||||
};
|
||||
})
|
||||
.toArray();
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const req = await fetchApi(this.site + '/' + novelPath + '/');
|
||||
const body = await req.text();
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h1.story__identity-title').text(),
|
||||
};
|
||||
|
||||
// novel.artist = '';
|
||||
novel.author = loadedCheerio('div.story__identity-meta')
|
||||
.text()
|
||||
.split('|')[0]
|
||||
.replace('Author: ', '')
|
||||
.replace('by ', '')
|
||||
.trim();
|
||||
novel.cover = loadedCheerio('figure.story__thumbnail > a').attr('href');
|
||||
novel.genres = loadedCheerio('div.tag-group > a, section.tag-group > a')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
novel.summary = loadedCheerio('section.story__summary').text();
|
||||
|
||||
novel.chapters = loadedCheerio('li.chapter-group__list-item._publish')
|
||||
.filter((i, el) => !el.attribs['class'].includes('_password'))
|
||||
.filter(
|
||||
(i, el) =>
|
||||
!loadedCheerio(el)
|
||||
.find('i')
|
||||
.first()!
|
||||
.attr('class')!
|
||||
.includes('fa-lock'),
|
||||
)
|
||||
.map((i, el) => {
|
||||
const chapterName = loadedCheerio(el).find('a').text();
|
||||
const chapterUrl = loadedCheerio(el)
|
||||
.find('a')
|
||||
.attr('href')
|
||||
?.replace(this.site + '/', '')
|
||||
.replace(/\/$/, '');
|
||||
|
||||
return {
|
||||
name: chapterName,
|
||||
path: chapterUrl!,
|
||||
};
|
||||
})
|
||||
.toArray();
|
||||
|
||||
const status = loadedCheerio('span.story__status').text().trim();
|
||||
if (status === 'Ongoing') novel.status = NovelStatus.Ongoing;
|
||||
if (status === 'Completed') novel.status = NovelStatus.Completed;
|
||||
if (status === 'Cancelled') novel.status = NovelStatus.Cancelled;
|
||||
if (status === 'Hiatus') novel.status = NovelStatus.OnHiatus;
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const req = await fetchApi(this.site + '/' + chapterPath + '/');
|
||||
const body = await req.text();
|
||||
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
return loadedCheerio('section#chapter-content > div').html() || '';
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const req = await fetchApi(
|
||||
this.site +
|
||||
`/${pageNo === 1 ? '' : 'page/' + pageNo + '/'}?s=${encodeURIComponent(searchTerm)}&post_type=fcn_story`,
|
||||
);
|
||||
const body = await req.text();
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
|
||||
return loadedCheerio('#search-result-list > li > div > div')
|
||||
.map((i, el) => {
|
||||
const novelName = loadedCheerio(el).find('h3 > a').text();
|
||||
const novelCover = loadedCheerio(el)
|
||||
.find('a.cell-img:has(img)')
|
||||
.attr('href');
|
||||
const novelUrl = loadedCheerio(el).find('h3 > a').attr('href');
|
||||
|
||||
return {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl!.replace(this.site + '/', '').replace(/\/$/, ''),
|
||||
};
|
||||
})
|
||||
.toArray();
|
||||
}
|
||||
|
||||
resolveUrl = (path: string, isNovel?: boolean) =>
|
||||
this.site + '/' + path + '/';
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// type GeneratedScript = {
|
||||
// lang: string;
|
||||
// filename: string;
|
||||
// pluginScript: string;
|
||||
// };
|
||||
|
||||
// export type ScrpitGeneratorFunction = () => GeneratedScript[];
|
||||
|
||||
const isScriptGenerator = s => {
|
||||
return !!s && typeof s === 'function';
|
||||
};
|
||||
|
||||
const generate = async name => {
|
||||
try {
|
||||
const generateAll = (await import(`./${name}/generator.js`)).generateAll;
|
||||
if (!isScriptGenerator(generateAll)) return false;
|
||||
const sources = generateAll();
|
||||
for (let source of sources) {
|
||||
const { lang, filename, pluginScript } = source;
|
||||
if (!lang || !filename || !pluginScript) {
|
||||
console.warn(name, ': lang, filename, pluginScript are required!');
|
||||
continue;
|
||||
}
|
||||
const pluginsDir = './plugins';
|
||||
const filePath = path.join(
|
||||
pluginsDir,
|
||||
lang.toLowerCase(),
|
||||
filename.replace(/[\s-.]+/g, '') + `[${name}].ts`,
|
||||
);
|
||||
fs.writeFileSync(filePath, pluginScript, { encoding: 'utf-8' });
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.log(`${name} is broken! ${e}\n`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const MULTISRC_DIR = './plugins/multisrc';
|
||||
|
||||
const run = async () => {
|
||||
const sources = fs
|
||||
.readdirSync(MULTISRC_DIR)
|
||||
.filter(
|
||||
name =>
|
||||
fs.lstatSync(path.join(MULTISRC_DIR, name)).isDirectory() &&
|
||||
!name.endsWith('.broken'),
|
||||
);
|
||||
|
||||
for (let name of sources) {
|
||||
const success = await generate(name);
|
||||
if (success) console.log(`[${name}] OK`);
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as fs from 'fs';
|
||||
import * as cheerio from 'cheerio';
|
||||
import * as path from 'path';
|
||||
import list from './sources.json' with { type: 'json' };
|
||||
import { HotNovelPubMetadata } from './template';
|
||||
|
||||
async function getFilters(sources: HotNovelPubMetadata) {
|
||||
const filters: any = {
|
||||
sort: {
|
||||
type: 'Picker',
|
||||
label: 'Order',
|
||||
value: 'hot',
|
||||
options: [],
|
||||
},
|
||||
category: {
|
||||
type: 'Picker',
|
||||
label: 'category',
|
||||
value: '',
|
||||
options: [{ label: 'NONE', value: '' }],
|
||||
},
|
||||
};
|
||||
const body = await fetch(sources.sourceSite).then(res => res.text());
|
||||
const $: cheerio.CheerioAPI = cheerio.load(body);
|
||||
$('.new-update').remove();
|
||||
|
||||
$('section > div').each((i, el) => {
|
||||
const id = $(el).find('a[class="see-all"]').attr('href');
|
||||
if (id) {
|
||||
filters.sort.options.push({
|
||||
label: $(el).find('[class="section-title"]').text().trim(),
|
||||
value: id.split('/').pop(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
filters.category.label = $('.category-title').text().trim();
|
||||
const apiSite = sources.sourceSite.replace('://', '://api.');
|
||||
const jsonRaw = await fetch(apiSite + '/categories', {
|
||||
headers: {
|
||||
lang: sources.options?.lang || 'en',
|
||||
},
|
||||
});
|
||||
const json = (await jsonRaw.json()) as response;
|
||||
|
||||
if (json.data?.length) {
|
||||
json.data
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.forEach(category =>
|
||||
filters.category.options.push({
|
||||
label: category.name,
|
||||
value: category.slug,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
type response = {
|
||||
status: number;
|
||||
message: string;
|
||||
data?: DataEntity[];
|
||||
};
|
||||
type DataEntity = {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
async function start() {
|
||||
const result = [];
|
||||
for (const sources of list) {
|
||||
console.log('updating the filters in', sources.sourceName);
|
||||
const NewFilters = await getFilters(sources as any);
|
||||
sources.filters = NewFilters;
|
||||
result.push(sources);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(__dirname, 'sources.json'),
|
||||
JSON.stringify(result, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
start();
|
||||
@@ -0,0 +1,63 @@
|
||||
import list from './sources.json' with { type: 'json' };
|
||||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const folder = dirname(fileURLToPath(import.meta.url));
|
||||
const FilterTypes = {
|
||||
TextInput: 'Text',
|
||||
Picker: 'Picker',
|
||||
CheckboxGroup: 'Checkbox',
|
||||
Switch: 'Switch',
|
||||
ExcludableCheckboxGroup: 'XCheckbox',
|
||||
};
|
||||
|
||||
const lang = {
|
||||
en: 'english',
|
||||
ru: 'russian',
|
||||
es: 'spanish',
|
||||
pt: 'portuguese',
|
||||
th: 'turkish',
|
||||
};
|
||||
|
||||
export const generateAll = function () {
|
||||
return list
|
||||
.map(p => {
|
||||
const filters = {};
|
||||
for (const k in p.filters) {
|
||||
const f = p.filters[k];
|
||||
if (f) {
|
||||
filters[k] = {
|
||||
...f,
|
||||
type: FilterTypes.Picker,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ...p, filters };
|
||||
})
|
||||
.map(metadata => {
|
||||
console.log(`[hotnovelpub]: Generating`, metadata.id);
|
||||
return generator(metadata);
|
||||
});
|
||||
};
|
||||
|
||||
const generator = function generator(metadata) {
|
||||
const HotNovelPubTemplate = readFileSync(join(folder, 'template.ts'), {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
const pluginScript = `
|
||||
${HotNovelPubTemplate}
|
||||
const plugin = new HotNovelPubPlugin(${JSON.stringify(metadata).replace(
|
||||
/"type":"([^"]+)"/g,
|
||||
'"type":FilterTypes.$1',
|
||||
)});
|
||||
export default plugin;
|
||||
`.trim();
|
||||
|
||||
return {
|
||||
lang: lang[metadata?.options?.lang || 'en'] || 'english',
|
||||
filename: metadata.sourceName,
|
||||
pluginScript,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,387 @@
|
||||
[
|
||||
{
|
||||
"id": "hotnovelpub",
|
||||
"sourceSite": "https://hotnovelpub.com",
|
||||
"sourceName": "HotNovelPub",
|
||||
"filters": {
|
||||
"sort": {
|
||||
"type": "Picker",
|
||||
"label": "Order",
|
||||
"value": "hot",
|
||||
"options": [
|
||||
{
|
||||
"label": "Hot story",
|
||||
"value": "hot"
|
||||
},
|
||||
{
|
||||
"label": "New story",
|
||||
"value": "new"
|
||||
},
|
||||
{
|
||||
"label": "Full story",
|
||||
"value": "full"
|
||||
}
|
||||
]
|
||||
},
|
||||
"category": {
|
||||
"type": "Picker",
|
||||
"label": "Category",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "NONE",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "Adult Romance",
|
||||
"value": "adult-romance"
|
||||
},
|
||||
{
|
||||
"label": "Fantasy",
|
||||
"value": "fantasy"
|
||||
},
|
||||
{
|
||||
"label": "LGBTQ+",
|
||||
"value": "lgbtq"
|
||||
},
|
||||
{
|
||||
"label": "Mystery",
|
||||
"value": "mystery"
|
||||
},
|
||||
{
|
||||
"label": "New Adult",
|
||||
"value": "new-adult"
|
||||
},
|
||||
{
|
||||
"label": "Paranormal",
|
||||
"value": "paranormal"
|
||||
},
|
||||
{
|
||||
"label": "Romance",
|
||||
"value": "romance"
|
||||
},
|
||||
{
|
||||
"label": "Steamy Stories",
|
||||
"value": "steamy-stories"
|
||||
},
|
||||
{
|
||||
"label": "Urban",
|
||||
"value": "urban"
|
||||
},
|
||||
{
|
||||
"label": "Werewolf/Vampire",
|
||||
"value": "werewolfvampire"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "eznovels",
|
||||
"sourceSite": "https://eznovels.com",
|
||||
"sourceName": "EzNovels",
|
||||
"filters": {
|
||||
"sort": {
|
||||
"type": "Picker",
|
||||
"label": "Order",
|
||||
"value": "hot",
|
||||
"options": [
|
||||
{
|
||||
"label": "Горячая история",
|
||||
"value": "hot"
|
||||
},
|
||||
{
|
||||
"label": "Новая история",
|
||||
"value": "new"
|
||||
},
|
||||
{
|
||||
"label": "Полный рассказ",
|
||||
"value": "full"
|
||||
}
|
||||
]
|
||||
},
|
||||
"category": {
|
||||
"type": "Picker",
|
||||
"label": "Категория",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "NONE",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "Ведьмы",
|
||||
"value": "vedmy"
|
||||
},
|
||||
{
|
||||
"label": "Городское фэнтези",
|
||||
"value": "gorodskoe-fentezi"
|
||||
},
|
||||
{
|
||||
"label": "Городской роман",
|
||||
"value": "gorodskoj-roman"
|
||||
},
|
||||
{
|
||||
"label": "Любовное фэнтези",
|
||||
"value": "lyubovnoe-fentezi"
|
||||
},
|
||||
{
|
||||
"label": "Оборотни",
|
||||
"value": "oborotni"
|
||||
},
|
||||
{
|
||||
"label": "Сверхъестественное",
|
||||
"value": "sverhuestestvennoe"
|
||||
},
|
||||
{
|
||||
"label": "Современный любовный роман",
|
||||
"value": "sovremennyj-lyubovnyj-roman"
|
||||
},
|
||||
{
|
||||
"label": "Триллеры",
|
||||
"value": "trillery"
|
||||
},
|
||||
{
|
||||
"label": "Фэнтези",
|
||||
"value": "fentezi"
|
||||
},
|
||||
{
|
||||
"label": "Эротика",
|
||||
"value": "erotika"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"lang": "ru"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lightnoveldaily",
|
||||
"sourceSite": "https://lightnoveldaily.com",
|
||||
"sourceName": "LightNovelDaily",
|
||||
"filters": {
|
||||
"sort": {
|
||||
"type": "Picker",
|
||||
"label": "Order",
|
||||
"value": "hot",
|
||||
"options": [
|
||||
{
|
||||
"label": "Historia caliente",
|
||||
"value": "hot"
|
||||
},
|
||||
{
|
||||
"label": "Nuevas cuentos",
|
||||
"value": "new"
|
||||
},
|
||||
{
|
||||
"label": "Historia completa",
|
||||
"value": "full"
|
||||
}
|
||||
]
|
||||
},
|
||||
"category": {
|
||||
"type": "Picker",
|
||||
"label": "Categoría",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "NONE",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "Acción/Aventura",
|
||||
"value": "accinaventura"
|
||||
},
|
||||
{
|
||||
"label": "Fantasía",
|
||||
"value": "fantasa"
|
||||
},
|
||||
{
|
||||
"label": "Ficción adolescente",
|
||||
"value": "ficcin-adolescente"
|
||||
},
|
||||
{
|
||||
"label": "Hombre lobo/Vampiro",
|
||||
"value": "hombre-lobovampiro"
|
||||
},
|
||||
{
|
||||
"label": "LGBTQ+",
|
||||
"value": "lgbtq"
|
||||
},
|
||||
{
|
||||
"label": "Mafia",
|
||||
"value": "mafia"
|
||||
},
|
||||
{
|
||||
"label": "Nuevo Adulto",
|
||||
"value": "nuevo-adulto"
|
||||
},
|
||||
{
|
||||
"label": "Pornografía",
|
||||
"value": "pornografa"
|
||||
},
|
||||
{
|
||||
"label": "Romántico",
|
||||
"value": "romntico"
|
||||
},
|
||||
{
|
||||
"label": "Urbano",
|
||||
"value": "urbano"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"lang": "es"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lanovels",
|
||||
"sourceSite": "https://lanovels.com",
|
||||
"sourceName": "LaNovels",
|
||||
"filters": {
|
||||
"sort": {
|
||||
"type": "Picker",
|
||||
"label": "Order",
|
||||
"value": "hot",
|
||||
"options": [
|
||||
{
|
||||
"label": "História quente",
|
||||
"value": "hot"
|
||||
},
|
||||
{
|
||||
"label": "Nova estória",
|
||||
"value": "new"
|
||||
},
|
||||
{
|
||||
"label": "História completa",
|
||||
"value": "full"
|
||||
}
|
||||
]
|
||||
},
|
||||
"category": {
|
||||
"type": "Picker",
|
||||
"label": "Categoria",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "NONE",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "Aventura",
|
||||
"value": "aventura"
|
||||
},
|
||||
{
|
||||
"label": "Erótico",
|
||||
"value": "erotico"
|
||||
},
|
||||
{
|
||||
"label": "Fantasia",
|
||||
"value": "fantasia"
|
||||
},
|
||||
{
|
||||
"label": "Ficção adolescente",
|
||||
"value": "ficcao-adolescente"
|
||||
},
|
||||
{
|
||||
"label": "Ficção científica",
|
||||
"value": "ficcao-cientifica"
|
||||
},
|
||||
{
|
||||
"label": "Lobisomens/Vampiros",
|
||||
"value": "lobisomens-vampiros"
|
||||
},
|
||||
{
|
||||
"label": "Mistério",
|
||||
"value": "misterio"
|
||||
},
|
||||
{
|
||||
"label": "Paranormal",
|
||||
"value": "paranormal"
|
||||
},
|
||||
{
|
||||
"label": "Romance",
|
||||
"value": "romance"
|
||||
},
|
||||
{
|
||||
"label": "Suspense/Terror",
|
||||
"value": "suspense-terror"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"lang": "pt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "thnovels",
|
||||
"sourceSite": "https://thnovels.com",
|
||||
"sourceName": "ThNovels",
|
||||
"filters": {
|
||||
"sort": {
|
||||
"type": "Picker",
|
||||
"label": "Order",
|
||||
"value": "hot",
|
||||
"options": [
|
||||
{
|
||||
"label": "เรื่องร้อน",
|
||||
"value": "hot"
|
||||
},
|
||||
{
|
||||
"label": "เรื่องใหม่",
|
||||
"value": "new"
|
||||
},
|
||||
{
|
||||
"label": "เรื่องเต็ม",
|
||||
"value": "full"
|
||||
}
|
||||
]
|
||||
},
|
||||
"category": {
|
||||
"type": "Picker",
|
||||
"label": "หมวดหมู่",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "NONE",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "ใช้ชีวิต",
|
||||
"value": "ใช้ชีวิต"
|
||||
},
|
||||
{
|
||||
"label": "นิยายY",
|
||||
"value": "นิยายy"
|
||||
},
|
||||
{
|
||||
"label": "นิยายสำหรับผู้ใหญ่",
|
||||
"value": "นิยายสำหรับผู้ใหญ่"
|
||||
},
|
||||
{
|
||||
"label": "ประวัติศาสตร์",
|
||||
"value": "ประวัติศาสตร์"
|
||||
},
|
||||
{
|
||||
"label": "แฟนตาซี",
|
||||
"value": "แฟนตาซี"
|
||||
},
|
||||
{
|
||||
"label": "มนุษย์หมาป่าแวมไพร์",
|
||||
"value": "มนุษย์หมาป่าแวมไพร์"
|
||||
},
|
||||
{
|
||||
"label": "โรแมนซ์",
|
||||
"value": "โรแมนซ์"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"lang": "th"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,275 @@
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
export type HotNovelPubMetadata = {
|
||||
id: string;
|
||||
sourceSite: string;
|
||||
sourceName: string;
|
||||
filters?: Filters;
|
||||
options?: HotNovelPubOptions;
|
||||
};
|
||||
|
||||
type HotNovelPubOptions = {
|
||||
lang?: string;
|
||||
};
|
||||
|
||||
class HotNovelPubPlugin implements Plugin.PluginBase {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
site: string;
|
||||
apiSite: string;
|
||||
version: string;
|
||||
filters?: Filters;
|
||||
lang: string;
|
||||
|
||||
constructor(metadata: HotNovelPubMetadata) {
|
||||
this.id = metadata.id;
|
||||
this.name = metadata.sourceName;
|
||||
this.icon = `multisrc/hotnovelpub/${metadata.id.toLowerCase()}/icon.png`;
|
||||
this.site = metadata.sourceSite;
|
||||
this.apiSite = metadata.sourceSite.replace('://', '://api.');
|
||||
this.version = '1.0.1';
|
||||
this.filters = metadata.filters;
|
||||
this.lang = metadata.options?.lang || 'en';
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ filters, showLatestNovels }: Plugin.PopularNovelsOptions,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.apiSite + '/books/';
|
||||
url += showLatestNovels ? 'new' : filters?.sort?.value || 'hot';
|
||||
if (filters?.category?.value) {
|
||||
url = this.apiSite + '/category/' + filters.category.value;
|
||||
}
|
||||
|
||||
url += '/?page=' + (pageNo - 1) + '&limit=20';
|
||||
|
||||
const result: responseNovels = await fetchApi(url, {
|
||||
headers: {
|
||||
lang: this.lang,
|
||||
},
|
||||
}).then(res => res.json());
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
if (result.status && result.data.books.data?.length) {
|
||||
result.data.books.data.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.name,
|
||||
cover: this.site + novel.image,
|
||||
path: novel.slug,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const json: responseNovel = await fetchApi(
|
||||
this.apiSite + '/book/' + novelPath,
|
||||
{
|
||||
headers: {
|
||||
lang: this.lang,
|
||||
},
|
||||
},
|
||||
).then(res => res.json());
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
name: json.data.book.name,
|
||||
path: novelPath,
|
||||
cover: this.site + json.data.book.image,
|
||||
summary: json.data.book.authorize.description,
|
||||
author: json.data.book.authorize.name,
|
||||
status:
|
||||
json.data.book.status === 'updating'
|
||||
? NovelStatus.Ongoing
|
||||
: NovelStatus.Completed,
|
||||
};
|
||||
|
||||
if (json.data.tags.tags_name?.length) {
|
||||
novel.genres = json.data.tags.tags_name.join(',');
|
||||
}
|
||||
|
||||
if (json.data.chapters?.length) {
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
json.data.chapters.forEach((chapter, chapterIndex) =>
|
||||
chapters.push({
|
||||
name: chapter.title,
|
||||
path: chapter.slug,
|
||||
releaseTime: undefined,
|
||||
chapterNumber: (chapter.index || chapterIndex) + 1,
|
||||
}),
|
||||
);
|
||||
|
||||
novel.chapters = chapters;
|
||||
}
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchApi(this.resolveUrl(chapterPath)).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
|
||||
let chapterText =
|
||||
body.match(/<div id="content-item" ([\s\S]*?)<\/div>/)?.[0] || '';
|
||||
|
||||
if (chapterText) {
|
||||
const result = await fetchApi(
|
||||
this.site + '/server/getContent?slug=' + chapterPath,
|
||||
);
|
||||
const json = (await result.json()) as ChapterType;
|
||||
|
||||
if (json.data) {
|
||||
chapterText += json.data
|
||||
.map(item => '<p>' + item + '</p>')
|
||||
.join('')
|
||||
.replace(/\n/g, '</p><p>')
|
||||
.replace(/\s/g, ' ');
|
||||
}
|
||||
}
|
||||
return chapterText.replace(/\.copy right hot novel pub/g, '');
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const result: responseSearch = await fetchApi(this.apiSite + '/search', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json;charset=utf-8',
|
||||
Referer: this.site,
|
||||
Origin: this.site,
|
||||
lang: this.lang,
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key_search: searchTerm }),
|
||||
}).then(res => res.json());
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
if (result.status && result.data.books?.length) {
|
||||
result.data.books.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.name,
|
||||
path: novel.slug,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return novels;
|
||||
}
|
||||
resolveUrl = (path: string) => this.site + '/' + path;
|
||||
}
|
||||
|
||||
type responseNovels = {
|
||||
status: number;
|
||||
message: string;
|
||||
data: Data;
|
||||
};
|
||||
type Data = {
|
||||
category?: Category;
|
||||
books: Books;
|
||||
};
|
||||
type Category = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
slug: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
countryId: number;
|
||||
};
|
||||
type Books = {
|
||||
total: number;
|
||||
pages_count: number;
|
||||
data?: DataEntity[];
|
||||
};
|
||||
type DataEntity = {
|
||||
id: number;
|
||||
name: string;
|
||||
view: number;
|
||||
status: string;
|
||||
image: string;
|
||||
slug: string;
|
||||
categories?: CategoriesEntity[] | null;
|
||||
source?: null;
|
||||
};
|
||||
type CategoriesEntity = {
|
||||
id: number;
|
||||
name: string;
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
type responseNovel = {
|
||||
status: number;
|
||||
message: string;
|
||||
data: Data1;
|
||||
};
|
||||
type Data1 = {
|
||||
book: Book1;
|
||||
tags: Tags;
|
||||
chapters?: ChaptersEntity[];
|
||||
};
|
||||
type Book1 = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
view: number;
|
||||
rate: number;
|
||||
status: string;
|
||||
image: string;
|
||||
createdAt: string;
|
||||
chapterCount: number;
|
||||
crawlStt: string;
|
||||
rateCount: number;
|
||||
slug: string;
|
||||
categories?: CategoriesEntity[] | null;
|
||||
authorize: Authorize;
|
||||
updated_at: string;
|
||||
source?: null;
|
||||
idSource?: null;
|
||||
prize?: null;
|
||||
};
|
||||
type Authorize = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
avatar: string;
|
||||
slug: string;
|
||||
};
|
||||
type Tags = {
|
||||
tags_name?: string[] | null;
|
||||
tags?: CategoriesEntity[] | null;
|
||||
};
|
||||
type ChaptersEntity = {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
index: number;
|
||||
};
|
||||
|
||||
type ChapterType = {
|
||||
type: string;
|
||||
data?: string[];
|
||||
};
|
||||
|
||||
type responseSearch = {
|
||||
status: number;
|
||||
message: string;
|
||||
data: Data2;
|
||||
};
|
||||
type Data2 = {
|
||||
books?: BooksEntity[] | null;
|
||||
authorizes?: AuthorizesEntityOrCategoriesEntity[] | null;
|
||||
categories?: AuthorizesEntityOrCategoriesEntity[] | null;
|
||||
};
|
||||
type BooksEntity = {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
type AuthorizesEntityOrCategoriesEntity = {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import list from './sources.json' with { type: 'json' };
|
||||
import defaultSettings from './settings.json' with { type: 'json' };
|
||||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const folder = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export const generateAll = function () {
|
||||
return list.map(metadata => {
|
||||
metadata.filters = Object.assign(defaultSettings.filters, metadata.filters);
|
||||
console.log(`[ifreedom]: Generating`, metadata.id);
|
||||
return generator(metadata);
|
||||
});
|
||||
};
|
||||
|
||||
const generator = function generator(metadata) {
|
||||
const IfreedomTemplate = readFileSync(join(folder, 'template.ts'), {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
const pluginScript = `
|
||||
${IfreedomTemplate}
|
||||
const plugin = new IfreedomPlugin(${JSON.stringify(metadata).replace(
|
||||
/"type":"([^"]+)"/g,
|
||||
'"type":FilterTypes.$1',
|
||||
)});
|
||||
export default plugin;
|
||||
`.trim();
|
||||
|
||||
return {
|
||||
lang: 'russian',
|
||||
filename: metadata.sourceName,
|
||||
pluginScript,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,260 @@
|
||||
{
|
||||
"filters": {
|
||||
"sort": {
|
||||
"type": "Picker",
|
||||
"label": "Сортировка:",
|
||||
"options": [
|
||||
{
|
||||
"label": "По дате добавления",
|
||||
"value": "По дате добавления"
|
||||
},
|
||||
{
|
||||
"label": "По дате обновления",
|
||||
"value": "По дате обновления"
|
||||
},
|
||||
{
|
||||
"label": "По количеству глав",
|
||||
"value": "По количеству глав"
|
||||
},
|
||||
{
|
||||
"label": "По названию",
|
||||
"value": "По названию"
|
||||
},
|
||||
{
|
||||
"label": "По просмотрам",
|
||||
"value": "По просмотрам"
|
||||
},
|
||||
{
|
||||
"label": "По рейтингу",
|
||||
"value": "По рейтингу"
|
||||
}
|
||||
],
|
||||
"value": "По рейтингу"
|
||||
},
|
||||
"status": {
|
||||
"type": "CheckboxGroup",
|
||||
"label": "Статус:",
|
||||
"options": [
|
||||
{
|
||||
"label": "Перевод активен",
|
||||
"value": "Перевод активен"
|
||||
},
|
||||
{
|
||||
"label": "Перевод приостановлен",
|
||||
"value": "Перевод приостановлен"
|
||||
},
|
||||
{
|
||||
"label": "Произведение завершено",
|
||||
"value": "Произведение завершено"
|
||||
}
|
||||
],
|
||||
"value": []
|
||||
},
|
||||
"lang": {
|
||||
"type": "CheckboxGroup",
|
||||
"label": "Язык:",
|
||||
"options": [
|
||||
{
|
||||
"label": "Английский",
|
||||
"value": "Английский"
|
||||
},
|
||||
{
|
||||
"label": "Китайский",
|
||||
"value": "Китайский"
|
||||
},
|
||||
{
|
||||
"label": "Корейский",
|
||||
"value": "Корейский"
|
||||
},
|
||||
{
|
||||
"label": "Японский",
|
||||
"value": "Японский"
|
||||
}
|
||||
],
|
||||
"value": []
|
||||
},
|
||||
"genre": {
|
||||
"type": "CheckboxGroup",
|
||||
"label": "Жанры:",
|
||||
"options": [
|
||||
{
|
||||
"label": "Боевик",
|
||||
"value": "Боевик"
|
||||
},
|
||||
{
|
||||
"label": "Боевые Искусства",
|
||||
"value": "Боевые Искусства"
|
||||
},
|
||||
{
|
||||
"label": "Вампиры",
|
||||
"value": "Вампиры"
|
||||
},
|
||||
{
|
||||
"label": "Виртуальный Мир",
|
||||
"value": "Виртуальный Мир"
|
||||
},
|
||||
{
|
||||
"label": "Гарем",
|
||||
"value": "Гарем"
|
||||
},
|
||||
{
|
||||
"label": "Героическое фэнтези",
|
||||
"value": "Героическое фэнтези"
|
||||
},
|
||||
{
|
||||
"label": "Детектив",
|
||||
"value": "Детектив"
|
||||
},
|
||||
{
|
||||
"label": "Дзёсэй",
|
||||
"value": "Дзёсэй"
|
||||
},
|
||||
{
|
||||
"label": "Драма",
|
||||
"value": "Драма"
|
||||
},
|
||||
{
|
||||
"label": "Игра",
|
||||
"value": "Игра"
|
||||
},
|
||||
{
|
||||
"label": "История",
|
||||
"value": "История"
|
||||
},
|
||||
{
|
||||
"label": "Киберпанк",
|
||||
"value": "Киберпанк"
|
||||
},
|
||||
{
|
||||
"label": "Комедия",
|
||||
"value": "Комедия"
|
||||
},
|
||||
{
|
||||
"label": "ЛитРПГ",
|
||||
"value": "ЛитРПГ"
|
||||
},
|
||||
{
|
||||
"label": "Меха",
|
||||
"value": "Меха"
|
||||
},
|
||||
{
|
||||
"label": "Милитари",
|
||||
"value": "Милитари"
|
||||
},
|
||||
{
|
||||
"label": "Мистика",
|
||||
"value": "Мистика"
|
||||
},
|
||||
{
|
||||
"label": "Научная Фантастика",
|
||||
"value": "Научная Фантастика"
|
||||
},
|
||||
{
|
||||
"label": "Повседневность",
|
||||
"value": "Повседневность"
|
||||
},
|
||||
{
|
||||
"label": "Постапокалипсис",
|
||||
"value": "Постапокалипсис"
|
||||
},
|
||||
{
|
||||
"label": "Приключения",
|
||||
"value": "Приключения"
|
||||
},
|
||||
{
|
||||
"label": "Психология",
|
||||
"value": "Психология"
|
||||
},
|
||||
{
|
||||
"label": "Романтика",
|
||||
"value": "Романтика"
|
||||
},
|
||||
{
|
||||
"label": "Сверхъестественное",
|
||||
"value": "Сверхъестественное"
|
||||
},
|
||||
{
|
||||
"label": "Сёдзё",
|
||||
"value": "Сёдзё"
|
||||
},
|
||||
{
|
||||
"label": "Сёнэн",
|
||||
"value": "Сёнэн"
|
||||
},
|
||||
{
|
||||
"label": "Сёнэн-ай",
|
||||
"value": "Сёнэн-ай"
|
||||
},
|
||||
{
|
||||
"label": "Спорт",
|
||||
"value": "Спорт"
|
||||
},
|
||||
{
|
||||
"label": "Сэйнэн",
|
||||
"value": "Сэйнэн"
|
||||
},
|
||||
{
|
||||
"label": "Сюаньхуа",
|
||||
"value": "Сюаньхуа"
|
||||
},
|
||||
{
|
||||
"label": "Трагедия",
|
||||
"value": "Трагедия"
|
||||
},
|
||||
{
|
||||
"label": "Триллер",
|
||||
"value": "Триллер"
|
||||
},
|
||||
{
|
||||
"label": "Ужасы",
|
||||
"value": "Ужасы"
|
||||
},
|
||||
{
|
||||
"label": "Фантастика",
|
||||
"value": "Фантастика"
|
||||
},
|
||||
{
|
||||
"label": "Фэнтези",
|
||||
"value": "Фэнтези"
|
||||
},
|
||||
{
|
||||
"label": "Школьная жизнь",
|
||||
"value": "Школьная жизнь"
|
||||
},
|
||||
{
|
||||
"label": "Экшн",
|
||||
"value": "Экшн"
|
||||
},
|
||||
{
|
||||
"label": "Эротика",
|
||||
"value": "Эротика"
|
||||
},
|
||||
{
|
||||
"label": "Этти",
|
||||
"value": "Этти"
|
||||
},
|
||||
{
|
||||
"label": "Яой",
|
||||
"value": "Яой"
|
||||
},
|
||||
{
|
||||
"label": "Adult",
|
||||
"value": "Adult"
|
||||
},
|
||||
{
|
||||
"label": "Mature",
|
||||
"value": "Mature"
|
||||
},
|
||||
{
|
||||
"label": "Xianxia",
|
||||
"value": "Xianxia"
|
||||
},
|
||||
{
|
||||
"label": "Xuanhuan",
|
||||
"value": "Xuanhuan"
|
||||
}
|
||||
],
|
||||
"value": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{
|
||||
"id": "ifreedom",
|
||||
"sourceSite": "https://ifreedom.su",
|
||||
"sourceName": "Свободный Мир Ранобэ",
|
||||
"filters": {}
|
||||
},
|
||||
{
|
||||
"id": "bookhamster",
|
||||
"sourceSite": "https://bookhamster.ru",
|
||||
"sourceName": "Bookhamster",
|
||||
"filters": {}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,222 @@
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export type IfreedomMetadata = {
|
||||
id: string;
|
||||
sourceSite: string;
|
||||
sourceName: string;
|
||||
filters?: Filters;
|
||||
};
|
||||
|
||||
class IfreedomPlugin implements Plugin.PluginBase {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
site: string;
|
||||
version: string;
|
||||
filters?: Filters;
|
||||
|
||||
constructor(metadata: IfreedomMetadata) {
|
||||
this.id = metadata.id;
|
||||
this.name = metadata.sourceName;
|
||||
this.icon = `multisrc/ifreedom/${metadata.id.toLowerCase()}/icon.png`;
|
||||
this.site = metadata.sourceSite;
|
||||
this.version = '1.0.2';
|
||||
this.filters = metadata.filters;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{
|
||||
filters,
|
||||
showLatestNovels,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url =
|
||||
this.site +
|
||||
'/vse-knigi/?sort=' +
|
||||
(showLatestNovels
|
||||
? 'По дате обновления'
|
||||
: filters?.sort?.value || 'По рейтингу');
|
||||
|
||||
Object.entries(filters || {}).forEach(([type, { value }]) => {
|
||||
if (value instanceof Array && value.length) {
|
||||
url += '&' + type + '[]=' + value.join('&' + type + '[]=');
|
||||
}
|
||||
});
|
||||
|
||||
url += '&bpage=' + page;
|
||||
|
||||
const body = await fetchApi(url).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = loadedCheerio(
|
||||
'div.one-book-home > div.img-home a',
|
||||
)
|
||||
.map((index, element) => ({
|
||||
name: loadedCheerio(element).attr('title') || '',
|
||||
cover: loadedCheerio(element).find('img').attr('src'),
|
||||
path:
|
||||
loadedCheerio(element).attr('href')?.replace?.(this.site, '') || '',
|
||||
}))
|
||||
.get()
|
||||
.filter(novel => novel.name && novel.path);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const body = await fetchApi(this.site + novelPath).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.entry-title').text(),
|
||||
cover: loadedCheerio('.img-ranobe > img').attr('src'),
|
||||
summary: loadedCheerio('meta[name="description"]').attr('content'),
|
||||
};
|
||||
|
||||
loadedCheerio('div.data-ranobe').each(function () {
|
||||
switch (loadedCheerio(this).find('b').text()) {
|
||||
case 'Автор':
|
||||
novel.author = loadedCheerio(this)
|
||||
.find('div.data-value')
|
||||
.text()
|
||||
.trim();
|
||||
break;
|
||||
case 'Жанры':
|
||||
novel.genres = loadedCheerio('div.data-value > a')
|
||||
.map((index, element) => loadedCheerio(element).text()?.trim())
|
||||
.get()
|
||||
.join(',');
|
||||
break;
|
||||
case 'Статус книги':
|
||||
novel.status = loadedCheerio('div.data-value')
|
||||
.text()
|
||||
.includes('активен')
|
||||
? NovelStatus.Ongoing
|
||||
: NovelStatus.Completed;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
if (novel.author == 'Не указан') delete novel.author;
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
const totalChapters = loadedCheerio('div.li-ranobe').length;
|
||||
|
||||
loadedCheerio('div.li-ranobe').each((chapterIndex, element) => {
|
||||
const name = loadedCheerio(element).find('a').text();
|
||||
const url = loadedCheerio(element).find('a').attr('href');
|
||||
if (
|
||||
!loadedCheerio(element).find('label.buy-ranobe').length &&
|
||||
name &&
|
||||
url
|
||||
) {
|
||||
const releaseDate = loadedCheerio(element)
|
||||
.find('div.li-col2-ranobe')
|
||||
.text()
|
||||
.trim();
|
||||
|
||||
chapters.push({
|
||||
name,
|
||||
path: url.replace(this.site, ''),
|
||||
releaseTime: this.parseDate(releaseDate),
|
||||
chapterNumber: totalChapters - chapterIndex,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
novel.chapters = chapters.reverse();
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchApi(this.site + chapterPath).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
let chapterText =
|
||||
body.match(/<article id="([\s\S]*?)<\/article>/)?.[0] || '';
|
||||
chapterText = chapterText.replace(/<script[^>]*>[\s\S]*?<\/script>/gim, '');
|
||||
|
||||
if (chapterText.includes('<img')) {
|
||||
return chapterText.replace(/srcset="([^"]+)"/g, (match, src) => {
|
||||
if (!src) return match;
|
||||
const bestlink = src
|
||||
.split(' ')
|
||||
.filter((url: string) => url.startsWith('http'))
|
||||
.pop();
|
||||
|
||||
if (bestlink) {
|
||||
return `src="${bestlink}"`;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number | undefined = 1,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url =
|
||||
this.site +
|
||||
'/vse-knigi/?searchname=' +
|
||||
encodeURIComponent(searchTerm) +
|
||||
'&bpage=' +
|
||||
page;
|
||||
const result = await fetchApi(url).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(result);
|
||||
|
||||
const novels: Plugin.NovelItem[] = loadedCheerio(
|
||||
'div.one-book-home > div.img-home a',
|
||||
)
|
||||
.map((index, element) => ({
|
||||
name: loadedCheerio(element).attr('title') || '',
|
||||
cover: loadedCheerio(element).find('img').attr('src'),
|
||||
path:
|
||||
loadedCheerio(element).attr('href')?.replace?.(this.site, '') || '',
|
||||
}))
|
||||
.get()
|
||||
.filter(novel => novel.name && novel.path);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
parseDate = (dateString: string | undefined = '') => {
|
||||
const months: Record<string, number> = {
|
||||
января: 1,
|
||||
февраля: 2,
|
||||
марта: 3,
|
||||
апреля: 4,
|
||||
мая: 5,
|
||||
июня: 6,
|
||||
июля: 7,
|
||||
августа: 8,
|
||||
сентября: 9,
|
||||
октября: 10,
|
||||
ноября: 11,
|
||||
декабря: 12,
|
||||
};
|
||||
|
||||
if (dateString.includes('.')) {
|
||||
const [day, month, year] = dateString.split('.');
|
||||
if (day && month && year) {
|
||||
return dayjs(year + '-' + month + '-' + day).format('LL');
|
||||
}
|
||||
} else if (dateString.includes(' ')) {
|
||||
const [day, month] = dateString.split(' ');
|
||||
if (day && months[month]) {
|
||||
const year = new Date().getFullYear();
|
||||
return dayjs(year + '-' + months[month] + '-' + day).format('LL');
|
||||
}
|
||||
}
|
||||
return dateString || null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import list from './sources.json' with { type: 'json' };
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const folder = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export const generateAll = function () {
|
||||
return list.map(source => {
|
||||
const exist = existsSync(join(folder, 'filters', source.id + '.json'));
|
||||
if (exist) {
|
||||
const filters = readFileSync(
|
||||
join(folder, 'filters', source.id + '.json'),
|
||||
);
|
||||
source.filters = JSON.parse(filters);
|
||||
}
|
||||
console.log(
|
||||
`[lightnovelworld] Generating: ${source.id}${' '.repeat(20 - source.id.length)} ${source.filters ? '🔎with filters🔍' : '🚫no filters🚫'}`,
|
||||
);
|
||||
return generator(source);
|
||||
});
|
||||
};
|
||||
|
||||
const generator = function generator(source) {
|
||||
const LightNovelWPTemplate = readFileSync(join(folder, 'template.ts'), {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
const pluginScript = `
|
||||
${LightNovelWPTemplate}
|
||||
const plugin = new LightNovelWorld(${JSON.stringify(source)});
|
||||
export default plugin;
|
||||
`.trim();
|
||||
|
||||
return {
|
||||
lang: source.options?.lang || 'English',
|
||||
filename: source.sourceName,
|
||||
pluginScript,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"id": "webnovelworld",
|
||||
"sourceName": "Web Novel Pub",
|
||||
"sourceSite": "https://www.webnovelworld.org/"
|
||||
},
|
||||
{
|
||||
"id": "lightnovelworld",
|
||||
"sourceName": "LightNovelWord",
|
||||
"sourceSite": "https://www.lightnovelworld.com/"
|
||||
},
|
||||
{
|
||||
"id": "lightnovelpubvip",
|
||||
"sourceName": "LightNovelPub Vip",
|
||||
"sourceSite": "https://lightnovelpub.vip/"
|
||||
},
|
||||
{
|
||||
"id": "lightnovelcave",
|
||||
"sourceName": "LightNovelCave",
|
||||
"sourceSite": "https://www.lightnovelcave.com/"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,262 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
type LightNovelWorldOptions = {
|
||||
lang?: string;
|
||||
versionIncrements?: number;
|
||||
};
|
||||
|
||||
export type LightNovelWorldMetadata = {
|
||||
id: string;
|
||||
sourceSite: string;
|
||||
sourceName: string;
|
||||
options?: LightNovelWorldOptions;
|
||||
filters?: any;
|
||||
};
|
||||
|
||||
class LightNovelWorld implements Plugin.PagePlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
site: string;
|
||||
version: string;
|
||||
icon: string;
|
||||
headers = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
imageRequestInit?: Plugin.ImageRequestInit | undefined = {
|
||||
headers: this.headers,
|
||||
};
|
||||
options?: LightNovelWorldOptions;
|
||||
|
||||
constructor(metadata: LightNovelWorldMetadata) {
|
||||
this.id = metadata.id;
|
||||
this.name = metadata.sourceName;
|
||||
this.icon = `multisrc/lightnovelworld/${metadata.id.toLowerCase()}/icon.png`;
|
||||
this.site = metadata.sourceSite;
|
||||
const versionIncrements = metadata.options?.versionIncrements || 0;
|
||||
this.version = `1.0.${1 + versionIncrements}`;
|
||||
this.options = metadata.options;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}browse/`;
|
||||
link += `${filters.genres.value}/`;
|
||||
link += `${filters.order.value}/`;
|
||||
link += `${filters.status.value}/`;
|
||||
link += page;
|
||||
|
||||
const body = await fetchApi(link).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.novel-item.ads').remove();
|
||||
|
||||
loadedCheerio('.novel-item').each((idx, ele) => {
|
||||
const novelName = loadedCheerio(ele).find('.novel-title').text().trim();
|
||||
const novelCover = loadedCheerio(ele).find('img').attr('data-src');
|
||||
const novelUrl = loadedCheerio(ele)
|
||||
.find('.novel-title > a')
|
||||
.attr('href')
|
||||
?.substring(1);
|
||||
|
||||
if (!novelUrl) return;
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl,
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(
|
||||
novelPath: string,
|
||||
): Promise<Plugin.SourceNovel & { totalPages: number }> {
|
||||
const body = await fetchApi(this.site + novelPath).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const totalChapters = parseInt(
|
||||
loadedCheerio('.header-stats span:first strong').text(),
|
||||
10,
|
||||
);
|
||||
|
||||
const novel: Plugin.SourceNovel & { totalPages: number } = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h1.novel-title').text().trim() || 'Untitled',
|
||||
cover: loadedCheerio('figure.cover > img').attr('data-src'),
|
||||
author: loadedCheerio('.author > a > span').text(),
|
||||
summary: loadedCheerio('.summary > .content').text().trim(),
|
||||
status: loadedCheerio('.header-stats span:last strong').text(),
|
||||
totalPages: Math.ceil(totalChapters / 100),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
novel.genres = loadedCheerio('.categories ul li')
|
||||
.map((a, ex) => loadedCheerio(ex).text().trim())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
|
||||
const url = this.site + novelPath + '/chapters/page-' + page;
|
||||
const body = await fetchApi(url).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
loadedCheerio('.chapter-list li').each(function () {
|
||||
const chapterName =
|
||||
'Chapter ' +
|
||||
loadedCheerio(this).find('.chapter-no').text().trim() +
|
||||
' - ' +
|
||||
loadedCheerio(this).find('.chapter-title').text().trim();
|
||||
|
||||
const releaseDate = loadedCheerio(this)
|
||||
.find('.chapter-update')
|
||||
.attr('datetime');
|
||||
|
||||
const chapterUrl = loadedCheerio(this)
|
||||
.find('a')
|
||||
.attr('href')
|
||||
?.substring(1);
|
||||
if (!chapterUrl) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl,
|
||||
releaseTime: dayjs(releaseDate).toISOString(),
|
||||
});
|
||||
});
|
||||
const chapters = chapter;
|
||||
return { chapters };
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchApi(this.site + chapterPath).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const chapterText = loadedCheerio('#chapter-container').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}lnsearchlive`;
|
||||
const link = `${this.site}search`;
|
||||
const response = await fetchApi(link).then(r => r.text());
|
||||
const token = parseHTML(response);
|
||||
const verifytoken = token('#novelSearchForm > input').attr('value');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('inputContent', searchTerm);
|
||||
|
||||
const results = await fetchApi(url, {
|
||||
method: 'POST',
|
||||
headers: { LNRequestVerifyToken: verifytoken! },
|
||||
body: formData,
|
||||
}).then(r => r.json());
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
const loadedCheerio = parseHTML(results.resultview);
|
||||
|
||||
loadedCheerio('.novel-item').each((idx, ele) => {
|
||||
const novelName = loadedCheerio(ele).find('h4.novel-title').text().trim();
|
||||
const novelCover = loadedCheerio(ele).find('img').attr('src');
|
||||
const novelUrl = loadedCheerio(ele).find('a').attr('href')?.substring(1);
|
||||
if (!novelUrl) return;
|
||||
novels.push({
|
||||
name: novelName,
|
||||
path: novelUrl,
|
||||
cover: novelCover,
|
||||
});
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
order: {
|
||||
value: 'popular',
|
||||
label: 'Order by',
|
||||
options: [
|
||||
{ label: 'New', value: 'new' },
|
||||
{ label: 'Popular', value: 'popular' },
|
||||
{ label: 'Updates', value: 'updated' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
status: {
|
||||
value: 'all',
|
||||
label: 'Status',
|
||||
options: [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Completed', value: 'completed' },
|
||||
{ label: 'Ongoing', value: 'ongoing' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genres: {
|
||||
value: 'all',
|
||||
label: 'Genre',
|
||||
options: [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Action', value: 'action' },
|
||||
{ label: 'Adventure', value: 'adventure' },
|
||||
{ label: 'Drama', value: 'drama' },
|
||||
{ label: 'Fantasy', value: 'fantasy' },
|
||||
{ label: 'Harem', value: 'harem' },
|
||||
{ label: 'Martial Arts', value: 'martial-arts' },
|
||||
{ label: 'Mature', value: 'mature' },
|
||||
{ label: 'Romance', value: 'romance' },
|
||||
{ label: 'Tragedy', value: 'tragedy' },
|
||||
{ label: 'Xuanhuan', value: 'xuanhuan' },
|
||||
{ label: 'Ecchi', value: 'ecchi' },
|
||||
{ label: 'Comedy', value: 'comedy' },
|
||||
{ label: 'Slice of Life', value: 'slice-of-life' },
|
||||
{ label: 'Mystery', value: 'mystery' },
|
||||
{ label: 'Supernatural', value: 'supernatural' },
|
||||
{ label: 'Psychological', value: 'psychological' },
|
||||
{ label: 'Sci-fi', value: 'sci-fi' },
|
||||
{ label: 'Xianxia', value: 'xianxia' },
|
||||
{ label: 'School Life', value: 'school-life' },
|
||||
{ label: 'Josei', value: 'josei' },
|
||||
{ label: 'Wuxia', value: 'wuxia' },
|
||||
{ label: 'Shounen', value: 'shounen' },
|
||||
{ label: 'Horror', value: 'horror' },
|
||||
{ label: 'Mecha', value: 'mecha' },
|
||||
{ label: 'Historical', value: 'historical' },
|
||||
{ label: 'Shoujo', value: 'shoujo' },
|
||||
{ label: 'Adult', value: 'adult' },
|
||||
{ label: 'Seinen', value: 'seinen' },
|
||||
{ label: 'Sports', value: 'sports' },
|
||||
{ label: 'Lolicon', value: 'lolicon' },
|
||||
{ label: 'Gender Bender', value: 'gender-bender' },
|
||||
{ label: 'Shounen Ai', value: 'shounen-ai' },
|
||||
{ label: 'Yaoi', value: 'yaoi' },
|
||||
{ label: 'Video Games', value: 'video-games' },
|
||||
{ label: 'Smut', value: 'smut' },
|
||||
{ label: 'Magical Realism', value: 'magical-realism' },
|
||||
{ label: 'Eastern Fantasy', value: 'eastern-fantasy' },
|
||||
{ label: 'Contemporary Romance', value: 'contemporary-romance' },
|
||||
{ label: 'Fantasy Romance', value: 'fantasy-romance' },
|
||||
{ label: 'Shoujo Ai', value: 'shoujo-ai' },
|
||||
{ label: 'Yuri', value: 'yuri' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
# LightnovelWP multisrc generator
|
||||
|
||||
## Compatiblity
|
||||
|
||||
### This generator is for most sites that uses the LightNovel WordPress Theme: https://themesia.com/lightnovel-wordpress-theme/
|
||||
|
||||
it should work for all sites that uses this theme (highest version tested: 1.1.5)
|
||||
|
||||
to know the name and version of the theme you can enter the url in : [WP Theme Detector](https://www.wpthemedetector.com)
|
||||
(it sometimes hallucinates for version 1.0.X as 1.0) \
|
||||
or you can check the version by adding "/wp-content/themes/lightnovel/style.css"
|
||||
to the site url and check the version in the file
|
||||
|
||||
## Add a new source
|
||||
|
||||
### sources.json
|
||||
|
||||
To add a new source you need to add it to sources.json:
|
||||
|
||||
- id: the id of the source (something unique)
|
||||
- sourceName: the name of the source (you can use the value of "name" in "https://site.com/wp-json/" if it exists)
|
||||
- sourceSite: the site url
|
||||
- options: the options of the source
|
||||
- lang: the language of the source (default: "English") (check that the language
|
||||
exists in the languages (check folder names in "plugins/"))
|
||||
- reverseChapter: if the chapters are in reverse order
|
||||
(if the first chapter of the list is 1 set it to false)
|
||||
- versionIncrements: needs to be incremented if the url is changed or customJS
|
||||
is changed or a change to the template affects only this source
|
||||
- seriesPath: the path to the series page (if the "Novels"/"All Series" button
|
||||
doesn't go to /series/ (ex: https://site.com/novels/ set it to "/novels/"))
|
||||
- customJS: custom javascript that will be excuted when getting the text (if
|
||||
the site has a custom copyright that need to be removed (the cherrio is $))
|
||||
|
||||
### icon
|
||||
|
||||
To add an icon to the source you can just run `npm run build:icons` to generate all icons (make sure `npm run build:multisrc` works on your machine)
|
||||
|
||||
Or you can manualy find the icon of the site \
|
||||
(try the favicon of the site (https://site.com/favicon.ico) most of the times it redirects you to an image named something like "cropped-site-32x32.png" try to access "cropped-site.png" or "site.png" if that did not work you can try to access "https://site.com/wp-json/" at the end of this very long file there should be a "site_icon_url" value
|
||||
) (don't forget to convert it to png)
|
||||
and add it to the folder "public/static/multisrc/lightnovelwp/{sourceID}/icon.png"
|
||||
|
||||
### filters
|
||||
|
||||
To add filters to a source you need to run the script "get_filters.js" \
|
||||
(`npx node plugins/multisrc/lightnovelwp/get_filters.js`
|
||||
(if you are at the root of the project) (and you have ran "npm install" before))
|
||||
and follow the instructions (url is easier and faster but sometimes it doesn't work)
|
||||
@@ -0,0 +1,196 @@
|
||||
{
|
||||
"filters": {
|
||||
"genre[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Género",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Action",
|
||||
"value": "action"
|
||||
},
|
||||
{
|
||||
"label": "Adult",
|
||||
"value": "adult"
|
||||
},
|
||||
{
|
||||
"label": "Adventure",
|
||||
"value": "adventure"
|
||||
},
|
||||
{
|
||||
"label": "Comedy",
|
||||
"value": "comedy"
|
||||
},
|
||||
{
|
||||
"label": "Drama",
|
||||
"value": "drama"
|
||||
},
|
||||
{
|
||||
"label": "Ecchi",
|
||||
"value": "ecchi"
|
||||
},
|
||||
{
|
||||
"label": "Fantasy",
|
||||
"value": "fantasy"
|
||||
},
|
||||
{
|
||||
"label": "Gender Bender",
|
||||
"value": "gender-bender"
|
||||
},
|
||||
{
|
||||
"label": "Harem",
|
||||
"value": "harem"
|
||||
},
|
||||
{
|
||||
"label": "Horror",
|
||||
"value": "horror"
|
||||
},
|
||||
{
|
||||
"label": "Josei",
|
||||
"value": "josei"
|
||||
},
|
||||
{
|
||||
"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": "Shounen",
|
||||
"value": "shounen"
|
||||
},
|
||||
{
|
||||
"label": "Slice of Life",
|
||||
"value": "slice-of-life"
|
||||
},
|
||||
{
|
||||
"label": "Supernatural",
|
||||
"value": "supernatural"
|
||||
},
|
||||
{
|
||||
"label": "Tragedy",
|
||||
"value": "tragedy"
|
||||
},
|
||||
{
|
||||
"label": "Xianxia",
|
||||
"value": "xianxia"
|
||||
},
|
||||
{
|
||||
"label": "Xuanhuan",
|
||||
"value": "xuanhuan"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Tipo",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Light Novel (JP)",
|
||||
"value": "light-novel-jp"
|
||||
},
|
||||
{
|
||||
"label": "Published Novel (JP)",
|
||||
"value": "published-novel-jp"
|
||||
},
|
||||
{
|
||||
"label": "Published Novel (KR)",
|
||||
"value": "published-novel-kr"
|
||||
},
|
||||
{
|
||||
"label": "Web Novel",
|
||||
"value": "web-novel"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"type": "Picker",
|
||||
"label": "Estado",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "Todos",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "Ongoing",
|
||||
"value": "ongoing"
|
||||
},
|
||||
{
|
||||
"label": "Hiatus",
|
||||
"value": "hiatus"
|
||||
},
|
||||
{
|
||||
"label": "Completed",
|
||||
"value": "completed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"order": {
|
||||
"type": "Picker",
|
||||
"label": "Ordenar por",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "Pred.",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "A-Z",
|
||||
"value": "title"
|
||||
},
|
||||
{
|
||||
"label": "Z-A",
|
||||
"value": "titlereverse"
|
||||
},
|
||||
{
|
||||
"label": "Actualización / Latest Release",
|
||||
"value": "update"
|
||||
},
|
||||
{
|
||||
"label": "Último Agregado",
|
||||
"value": "latest"
|
||||
},
|
||||
{
|
||||
"label": "Popular",
|
||||
"value": "popular"
|
||||
},
|
||||
{
|
||||
"label": "Puntuación",
|
||||
"value": "rating"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,232 @@
|
||||
{
|
||||
"filters": {
|
||||
"genre[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Genre",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Academy",
|
||||
"value": "academy"
|
||||
},
|
||||
{
|
||||
"label": "Action",
|
||||
"value": "action"
|
||||
},
|
||||
{
|
||||
"label": "Adventure",
|
||||
"value": "adventure"
|
||||
},
|
||||
{
|
||||
"label": "Apocalypse",
|
||||
"value": "apocalypse"
|
||||
},
|
||||
{
|
||||
"label": "Comedy",
|
||||
"value": "comedy"
|
||||
},
|
||||
{
|
||||
"label": "Cyberpunk",
|
||||
"value": "cyberpunk"
|
||||
},
|
||||
{
|
||||
"label": "Drama",
|
||||
"value": "drama"
|
||||
},
|
||||
{
|
||||
"label": "Ecchi",
|
||||
"value": "ecchi"
|
||||
},
|
||||
{
|
||||
"label": "Fantasy",
|
||||
"value": "fantasy"
|
||||
},
|
||||
{
|
||||
"label": "Fusion",
|
||||
"value": "fusion"
|
||||
},
|
||||
{
|
||||
"label": "Game World",
|
||||
"value": "game-world"
|
||||
},
|
||||
{
|
||||
"label": "Harem",
|
||||
"value": "harem"
|
||||
},
|
||||
{
|
||||
"label": "Martial Arts",
|
||||
"value": "martial-arts"
|
||||
},
|
||||
{
|
||||
"label": "Medieval",
|
||||
"value": "medieval"
|
||||
},
|
||||
{
|
||||
"label": "Misunderstandings",
|
||||
"value": "misunderstandings"
|
||||
},
|
||||
{
|
||||
"label": "Modern",
|
||||
"value": "modern"
|
||||
},
|
||||
{
|
||||
"label": "Mystery",
|
||||
"value": "mystery"
|
||||
},
|
||||
{
|
||||
"label": "Obsession",
|
||||
"value": "obsession"
|
||||
},
|
||||
{
|
||||
"label": "Possession",
|
||||
"value": "possession"
|
||||
},
|
||||
{
|
||||
"label": "Psychological",
|
||||
"value": "psychological"
|
||||
},
|
||||
{
|
||||
"label": "Regression",
|
||||
"value": "regression"
|
||||
},
|
||||
{
|
||||
"label": "Regret",
|
||||
"value": "regret"
|
||||
},
|
||||
{
|
||||
"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": "Shounen",
|
||||
"value": "shounen"
|
||||
},
|
||||
{
|
||||
"label": "Slice of Life",
|
||||
"value": "slice-of-life"
|
||||
},
|
||||
{
|
||||
"label": "Smut",
|
||||
"value": "smut"
|
||||
},
|
||||
{
|
||||
"label": "Supernatural",
|
||||
"value": "supernatural"
|
||||
},
|
||||
{
|
||||
"label": "Tragedy",
|
||||
"value": "tragedy"
|
||||
},
|
||||
{
|
||||
"label": "Transmigration",
|
||||
"value": "transmigration"
|
||||
},
|
||||
{
|
||||
"label": "War",
|
||||
"value": "war"
|
||||
},
|
||||
{
|
||||
"label": "Wuxia",
|
||||
"value": "wuxia"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Type",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Chinese Novel",
|
||||
"value": "chinese-novel"
|
||||
},
|
||||
{
|
||||
"label": "Japanese Novel",
|
||||
"value": "japanese-novel"
|
||||
},
|
||||
{
|
||||
"label": "Korean Novel",
|
||||
"value": "korean-novel"
|
||||
},
|
||||
{
|
||||
"label": "Web Novel",
|
||||
"value": "web-novel"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"type": "Picker",
|
||||
"label": "Status",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "All",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "Ongoing",
|
||||
"value": "ongoing"
|
||||
},
|
||||
{
|
||||
"label": "Hiatus",
|
||||
"value": "hiatus"
|
||||
},
|
||||
{
|
||||
"label": "Completed",
|
||||
"value": "completed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"order": {
|
||||
"type": "Picker",
|
||||
"label": "Order by",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "Default",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "A-Z",
|
||||
"value": "title"
|
||||
},
|
||||
{
|
||||
"label": "Z-A",
|
||||
"value": "titlereverse"
|
||||
},
|
||||
{
|
||||
"label": "Latest Update",
|
||||
"value": "update"
|
||||
},
|
||||
{
|
||||
"label": "Latest Added",
|
||||
"value": "latest"
|
||||
},
|
||||
{
|
||||
"label": "Popular",
|
||||
"value": "popular"
|
||||
},
|
||||
{
|
||||
"label": "Rating",
|
||||
"value": "rating"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
{
|
||||
"filters": {
|
||||
"genre[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Genre",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Action",
|
||||
"value": "action"
|
||||
},
|
||||
{
|
||||
"label": "Adult",
|
||||
"value": "adult"
|
||||
},
|
||||
{
|
||||
"label": "Adventure",
|
||||
"value": "adventure"
|
||||
},
|
||||
{
|
||||
"label": "Antihero",
|
||||
"value": "antihero"
|
||||
},
|
||||
{
|
||||
"label": "Comedy",
|
||||
"value": "comedy"
|
||||
},
|
||||
{
|
||||
"label": "Drama",
|
||||
"value": "drama"
|
||||
},
|
||||
{
|
||||
"label": "Ecchi",
|
||||
"value": "ecchi"
|
||||
},
|
||||
{
|
||||
"label": "Fantasy",
|
||||
"value": "fantasy"
|
||||
},
|
||||
{
|
||||
"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": "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": "Shoujo Ai",
|
||||
"value": "shoujo-ai"
|
||||
},
|
||||
{
|
||||
"label": "Shounen",
|
||||
"value": "shounen"
|
||||
},
|
||||
{
|
||||
"label": "Shounen Ai",
|
||||
"value": "shounen-ai"
|
||||
},
|
||||
{
|
||||
"label": "Slice of Life",
|
||||
"value": "slice-of-life"
|
||||
},
|
||||
{
|
||||
"label": "Sports",
|
||||
"value": "sports"
|
||||
},
|
||||
{
|
||||
"label": "Superhero",
|
||||
"value": "superhero"
|
||||
},
|
||||
{
|
||||
"label": "Supernatural",
|
||||
"value": "supernatural"
|
||||
},
|
||||
{
|
||||
"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[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Tipe",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "Manga",
|
||||
"value": "manga"
|
||||
},
|
||||
{
|
||||
"label": "Novel",
|
||||
"value": "novel"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"type": "Picker",
|
||||
"label": "Status",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "All",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "Ongoing",
|
||||
"value": "ongoing"
|
||||
},
|
||||
{
|
||||
"label": "Hiatus",
|
||||
"value": "hiatus"
|
||||
},
|
||||
{
|
||||
"label": "Completed",
|
||||
"value": "completed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"order": {
|
||||
"type": "Picker",
|
||||
"label": "Urutkan",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "Default",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "A-Z",
|
||||
"value": "title"
|
||||
},
|
||||
{
|
||||
"label": "Z-A",
|
||||
"value": "titlereverse"
|
||||
},
|
||||
{
|
||||
"label": "Baru diperbarui",
|
||||
"value": "update"
|
||||
},
|
||||
{
|
||||
"label": "Baru ditambahkan",
|
||||
"value": "latest"
|
||||
},
|
||||
{
|
||||
"label": "Terpopuler",
|
||||
"value": "popular"
|
||||
},
|
||||
{
|
||||
"label": "Rating",
|
||||
"value": "rating"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"filters": {
|
||||
"genre[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Gênero",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Ação",
|
||||
"value": "acao"
|
||||
},
|
||||
{
|
||||
"label": "Action",
|
||||
"value": "action"
|
||||
},
|
||||
{
|
||||
"label": "Adulto",
|
||||
"value": "adulto"
|
||||
},
|
||||
{
|
||||
"label": "Adventure",
|
||||
"value": "adventure"
|
||||
},
|
||||
{
|
||||
"label": "Artes Marciais",
|
||||
"value": "artes-marciais"
|
||||
},
|
||||
{
|
||||
"label": "Aventura",
|
||||
"value": "aventura"
|
||||
},
|
||||
{
|
||||
"label": "Comédia",
|
||||
"value": "comedia"
|
||||
},
|
||||
{
|
||||
"label": "Cotidiano",
|
||||
"value": "cotidiano"
|
||||
},
|
||||
{
|
||||
"label": "Cultivo",
|
||||
"value": "cultivo"
|
||||
},
|
||||
{
|
||||
"label": "Drama",
|
||||
"value": "drama"
|
||||
},
|
||||
{
|
||||
"label": "Ecchi",
|
||||
"value": "ecchi"
|
||||
},
|
||||
{
|
||||
"label": "Escolar",
|
||||
"value": "escolar"
|
||||
},
|
||||
{
|
||||
"label": "Esportes",
|
||||
"value": "esportes"
|
||||
},
|
||||
{
|
||||
"label": "Evolução",
|
||||
"value": "evolucao"
|
||||
},
|
||||
{
|
||||
"label": "Fantasia",
|
||||
"value": "fantasia"
|
||||
},
|
||||
{
|
||||
"label": "Fantasy",
|
||||
"value": "fantasy"
|
||||
},
|
||||
{
|
||||
"label": "Ficção Científica",
|
||||
"value": "ficcao-cientifica"
|
||||
},
|
||||
{
|
||||
"label": "Gender Bender",
|
||||
"value": "gender-bender"
|
||||
},
|
||||
{
|
||||
"label": "Harém",
|
||||
"value": "harem"
|
||||
},
|
||||
{
|
||||
"label": "Histórico",
|
||||
"value": "historico"
|
||||
},
|
||||
{
|
||||
"label": "Isekai",
|
||||
"value": "isekai"
|
||||
},
|
||||
{
|
||||
"label": "Josei",
|
||||
"value": "josei"
|
||||
},
|
||||
{
|
||||
"label": "LitRPG",
|
||||
"value": "litrpg"
|
||||
},
|
||||
{
|
||||
"label": "Magia",
|
||||
"value": "magia"
|
||||
},
|
||||
{
|
||||
"label": "Mecha",
|
||||
"value": "mecha"
|
||||
},
|
||||
{
|
||||
"label": "Medieval",
|
||||
"value": "medieval"
|
||||
},
|
||||
{
|
||||
"label": "Mistério",
|
||||
"value": "misterio"
|
||||
},
|
||||
{
|
||||
"label": "Mitologia",
|
||||
"value": "mitologia"
|
||||
},
|
||||
{
|
||||
"label": "Monstros",
|
||||
"value": "monstros"
|
||||
},
|
||||
{
|
||||
"label": "Pet",
|
||||
"value": "pet"
|
||||
},
|
||||
{
|
||||
"label": "Protagonista Feminina",
|
||||
"value": "protagonista-feminina"
|
||||
},
|
||||
{
|
||||
"label": "Protagonista Maligno",
|
||||
"value": "protagonista-maligno"
|
||||
},
|
||||
{
|
||||
"label": "Psicológico",
|
||||
"value": "psicologico"
|
||||
},
|
||||
{
|
||||
"label": "Psychological",
|
||||
"value": "psychological"
|
||||
},
|
||||
{
|
||||
"label": "Reencarnação",
|
||||
"value": "reencarnacao"
|
||||
},
|
||||
{
|
||||
"label": "Romance",
|
||||
"value": "romance"
|
||||
},
|
||||
{
|
||||
"label": "Seinen",
|
||||
"value": "seinen"
|
||||
},
|
||||
{
|
||||
"label": "Shoujo",
|
||||
"value": "shoujo"
|
||||
},
|
||||
{
|
||||
"label": "Shounen",
|
||||
"value": "shounen"
|
||||
},
|
||||
{
|
||||
"label": "Shounen BL",
|
||||
"value": "shounen-bl"
|
||||
},
|
||||
{
|
||||
"label": "Sistema",
|
||||
"value": "sistema"
|
||||
},
|
||||
{
|
||||
"label": "Sistema de Jogo",
|
||||
"value": "sistema-de-jogo"
|
||||
},
|
||||
{
|
||||
"label": "Slice of Life",
|
||||
"value": "slice-of-life"
|
||||
},
|
||||
{
|
||||
"label": "Sobrenatural",
|
||||
"value": "sobrenatural"
|
||||
},
|
||||
{
|
||||
"label": "Supernatural",
|
||||
"value": "supernatural"
|
||||
},
|
||||
{
|
||||
"label": "Terror",
|
||||
"value": "terror"
|
||||
},
|
||||
{
|
||||
"label": "Tragédia",
|
||||
"value": "tragedia"
|
||||
},
|
||||
{
|
||||
"label": "Transmigração",
|
||||
"value": "transmigracao"
|
||||
},
|
||||
{
|
||||
"label": "Vida Escolar",
|
||||
"value": "vida-escolar"
|
||||
},
|
||||
{
|
||||
"label": "VRMMO",
|
||||
"value": "vrmmo"
|
||||
},
|
||||
{
|
||||
"label": "Wuxia",
|
||||
"value": "wuxia"
|
||||
},
|
||||
{
|
||||
"label": "Xianxia",
|
||||
"value": "xianxia"
|
||||
},
|
||||
{
|
||||
"label": "Xuanhuan",
|
||||
"value": "xuanhuan"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Tipo",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Light Novel",
|
||||
"value": "light-novel"
|
||||
},
|
||||
{
|
||||
"label": "Novel Chinesa",
|
||||
"value": "novel-chinesa"
|
||||
},
|
||||
{
|
||||
"label": "Novel Coreana",
|
||||
"value": "novel-coreana"
|
||||
},
|
||||
{
|
||||
"label": "Novel Japonesa",
|
||||
"value": "novel-japonesa"
|
||||
},
|
||||
{
|
||||
"label": "Novel Ocidental",
|
||||
"value": "novel-ocidental"
|
||||
},
|
||||
{
|
||||
"label": "Webnovel",
|
||||
"value": "webnovel"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"type": "Picker",
|
||||
"label": "Status",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "Todos",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "Em andamento",
|
||||
"value": "em andamento"
|
||||
},
|
||||
{
|
||||
"label": "Hiato",
|
||||
"value": "hiato"
|
||||
},
|
||||
{
|
||||
"label": "Completo",
|
||||
"value": "completo"
|
||||
}
|
||||
]
|
||||
},
|
||||
"order": {
|
||||
"type": "Picker",
|
||||
"label": "Ordenar por",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "Padrão",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "A-Z",
|
||||
"value": "title"
|
||||
},
|
||||
{
|
||||
"label": "Z-A",
|
||||
"value": "titlereverse"
|
||||
},
|
||||
{
|
||||
"label": "Últ. Att",
|
||||
"value": "update"
|
||||
},
|
||||
{
|
||||
"label": "Últ. Add",
|
||||
"value": "latest"
|
||||
},
|
||||
{
|
||||
"label": "Populares",
|
||||
"value": "popular"
|
||||
},
|
||||
{
|
||||
"label": "Avaliação",
|
||||
"value": "rating"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
{
|
||||
"filters": {
|
||||
"genre[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Genre",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Action",
|
||||
"value": "action"
|
||||
},
|
||||
{
|
||||
"label": "Adult",
|
||||
"value": "adult"
|
||||
},
|
||||
{
|
||||
"label": "Adventure",
|
||||
"value": "adventure"
|
||||
},
|
||||
{
|
||||
"label": "Billionaire",
|
||||
"value": "billionaire"
|
||||
},
|
||||
{
|
||||
"label": "Comedy",
|
||||
"value": "comedy"
|
||||
},
|
||||
{
|
||||
"label": "Contemporary",
|
||||
"value": "contemporary"
|
||||
},
|
||||
{
|
||||
"label": "Drama",
|
||||
"value": "drama"
|
||||
},
|
||||
{
|
||||
"label": "Ecchi",
|
||||
"value": "ecchi"
|
||||
},
|
||||
{
|
||||
"label": "Family",
|
||||
"value": "family"
|
||||
},
|
||||
{
|
||||
"label": "Fantasy",
|
||||
"value": "fantasy"
|
||||
},
|
||||
{
|
||||
"label": "Gender Bender",
|
||||
"value": "gender-bender"
|
||||
},
|
||||
{
|
||||
"label": "Growth",
|
||||
"value": "growth"
|
||||
},
|
||||
{
|
||||
"label": "Harem",
|
||||
"value": "harem"
|
||||
},
|
||||
{
|
||||
"label": "Hidden Identity",
|
||||
"value": "hidden-identity"
|
||||
},
|
||||
{
|
||||
"label": "Historical",
|
||||
"value": "historical"
|
||||
},
|
||||
{
|
||||
"label": "Horror",
|
||||
"value": "horror"
|
||||
},
|
||||
{
|
||||
"label": "Isekai",
|
||||
"value": "isekai"
|
||||
},
|
||||
{
|
||||
"label": "Korean",
|
||||
"value": "korean"
|
||||
},
|
||||
{
|
||||
"label": "Magic",
|
||||
"value": "magic"
|
||||
},
|
||||
{
|
||||
"label": "Manhwa",
|
||||
"value": "manhwa"
|
||||
},
|
||||
{
|
||||
"label": "Marriage",
|
||||
"value": "marriage"
|
||||
},
|
||||
{
|
||||
"label": "Martial Arts",
|
||||
"value": "martial-arts"
|
||||
},
|
||||
{
|
||||
"label": "Mature",
|
||||
"value": "mature"
|
||||
},
|
||||
{
|
||||
"label": "Mecha",
|
||||
"value": "mecha"
|
||||
},
|
||||
{
|
||||
"label": "Murim",
|
||||
"value": "murim"
|
||||
},
|
||||
{
|
||||
"label": "Mystery",
|
||||
"value": "mystery"
|
||||
},
|
||||
{
|
||||
"label": "Overpowered",
|
||||
"value": "overpowered"
|
||||
},
|
||||
{
|
||||
"label": "Psychological",
|
||||
"value": "psychological"
|
||||
},
|
||||
{
|
||||
"label": "Regression",
|
||||
"value": "regression"
|
||||
},
|
||||
{
|
||||
"label": "Reincarnation",
|
||||
"value": "reincarnation"
|
||||
},
|
||||
{
|
||||
"label": "Revenge",
|
||||
"value": "revenge"
|
||||
},
|
||||
{
|
||||
"label": "Rich",
|
||||
"value": "rich"
|
||||
},
|
||||
{
|
||||
"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": "Son-in-Law",
|
||||
"value": "son-in-law"
|
||||
},
|
||||
{
|
||||
"label": "Sports",
|
||||
"value": "sports"
|
||||
},
|
||||
{
|
||||
"label": "Supernatural",
|
||||
"value": "supernatural"
|
||||
},
|
||||
{
|
||||
"label": "Superpower",
|
||||
"value": "superpower"
|
||||
},
|
||||
{
|
||||
"label": "Tragedy",
|
||||
"value": "tragedy"
|
||||
},
|
||||
{
|
||||
"label": "Urban",
|
||||
"value": "urban"
|
||||
},
|
||||
{
|
||||
"label": "Virtual Game",
|
||||
"value": "virtual-game"
|
||||
},
|
||||
{
|
||||
"label": "Warrior",
|
||||
"value": "warrior"
|
||||
},
|
||||
{
|
||||
"label": "Webtoons",
|
||||
"value": "webtoons"
|
||||
},
|
||||
{
|
||||
"label": "Wuxia",
|
||||
"value": "wuxia"
|
||||
},
|
||||
{
|
||||
"label": "Xianxia",
|
||||
"value": "xianxia"
|
||||
},
|
||||
{
|
||||
"label": "Xuanhuan",
|
||||
"value": "xuanhuan"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Type",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Action",
|
||||
"value": "action"
|
||||
},
|
||||
{
|
||||
"label": "Adventure",
|
||||
"value": "adventure"
|
||||
},
|
||||
{
|
||||
"label": "Chinese Novel",
|
||||
"value": "chinese-novel"
|
||||
},
|
||||
{
|
||||
"label": "Fantasy",
|
||||
"value": "fantasy"
|
||||
},
|
||||
{
|
||||
"label": "Korean Novel",
|
||||
"value": "korean-novel"
|
||||
},
|
||||
{
|
||||
"label": "Light Novel (KR)",
|
||||
"value": "light-novel-kr"
|
||||
},
|
||||
{
|
||||
"label": "Martial Arts",
|
||||
"value": "martial-arts"
|
||||
},
|
||||
{
|
||||
"label": "Published Novel (KR)",
|
||||
"value": "published-novel-kr"
|
||||
},
|
||||
{
|
||||
"label": "Romance",
|
||||
"value": "romance"
|
||||
},
|
||||
{
|
||||
"label": "Supernatural",
|
||||
"value": "supernatural"
|
||||
},
|
||||
{
|
||||
"label": "Web Novel",
|
||||
"value": "web-novel"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"type": "Picker",
|
||||
"label": "Status",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "All",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "Ongoing",
|
||||
"value": "ongoing"
|
||||
},
|
||||
{
|
||||
"label": "Hiatus",
|
||||
"value": "hiatus"
|
||||
},
|
||||
{
|
||||
"label": "Completed",
|
||||
"value": "completed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"order": {
|
||||
"type": "Picker",
|
||||
"label": "Order by",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "Default",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "A-Z",
|
||||
"value": "title"
|
||||
},
|
||||
{
|
||||
"label": "Z-A",
|
||||
"value": "titlereverse"
|
||||
},
|
||||
{
|
||||
"label": "Latest Update",
|
||||
"value": "update"
|
||||
},
|
||||
{
|
||||
"label": "Latest Added",
|
||||
"value": "latest"
|
||||
},
|
||||
{
|
||||
"label": "Popular",
|
||||
"value": "popular"
|
||||
},
|
||||
{
|
||||
"label": "Rating",
|
||||
"value": "rating"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
{
|
||||
"filters": {
|
||||
"genre[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Genre",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Action",
|
||||
"value": "action"
|
||||
},
|
||||
{
|
||||
"label": "Adult",
|
||||
"value": "adult"
|
||||
},
|
||||
{
|
||||
"label": "Adventure",
|
||||
"value": "adventure"
|
||||
},
|
||||
{
|
||||
"label": "Billionaire",
|
||||
"value": "billionaire"
|
||||
},
|
||||
{
|
||||
"label": "Comedy",
|
||||
"value": "comedy"
|
||||
},
|
||||
{
|
||||
"label": "Contemporary",
|
||||
"value": "contemporary"
|
||||
},
|
||||
{
|
||||
"label": "Drama",
|
||||
"value": "drama"
|
||||
},
|
||||
{
|
||||
"label": "Ecchi",
|
||||
"value": "ecchi"
|
||||
},
|
||||
{
|
||||
"label": "Family",
|
||||
"value": "family"
|
||||
},
|
||||
{
|
||||
"label": "Fantasy",
|
||||
"value": "fantasy"
|
||||
},
|
||||
{
|
||||
"label": "Gender Bender",
|
||||
"value": "gender-bender"
|
||||
},
|
||||
{
|
||||
"label": "Growth",
|
||||
"value": "growth"
|
||||
},
|
||||
{
|
||||
"label": "Harem",
|
||||
"value": "harem"
|
||||
},
|
||||
{
|
||||
"label": "Hidden Identity",
|
||||
"value": "hidden-identity"
|
||||
},
|
||||
{
|
||||
"label": "Historical",
|
||||
"value": "historical"
|
||||
},
|
||||
{
|
||||
"label": "Horror",
|
||||
"value": "horror"
|
||||
},
|
||||
{
|
||||
"label": "Isekai",
|
||||
"value": "isekai"
|
||||
},
|
||||
{
|
||||
"label": "Korean",
|
||||
"value": "korean"
|
||||
},
|
||||
{
|
||||
"label": "Magic",
|
||||
"value": "magic"
|
||||
},
|
||||
{
|
||||
"label": "Manhwa",
|
||||
"value": "manhwa"
|
||||
},
|
||||
{
|
||||
"label": "Marriage",
|
||||
"value": "marriage"
|
||||
},
|
||||
{
|
||||
"label": "Martial Arts",
|
||||
"value": "martial-arts"
|
||||
},
|
||||
{
|
||||
"label": "Mature",
|
||||
"value": "mature"
|
||||
},
|
||||
{
|
||||
"label": "Mecha",
|
||||
"value": "mecha"
|
||||
},
|
||||
{
|
||||
"label": "Murim",
|
||||
"value": "murim"
|
||||
},
|
||||
{
|
||||
"label": "Mystery",
|
||||
"value": "mystery"
|
||||
},
|
||||
{
|
||||
"label": "Overpowered",
|
||||
"value": "overpowered"
|
||||
},
|
||||
{
|
||||
"label": "Psychological",
|
||||
"value": "psychological"
|
||||
},
|
||||
{
|
||||
"label": "Regression",
|
||||
"value": "regression"
|
||||
},
|
||||
{
|
||||
"label": "Reincarnation",
|
||||
"value": "reincarnation"
|
||||
},
|
||||
{
|
||||
"label": "Revenge",
|
||||
"value": "revenge"
|
||||
},
|
||||
{
|
||||
"label": "Rich",
|
||||
"value": "rich"
|
||||
},
|
||||
{
|
||||
"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": "Son-in-Law",
|
||||
"value": "son-in-law"
|
||||
},
|
||||
{
|
||||
"label": "Sports",
|
||||
"value": "sports"
|
||||
},
|
||||
{
|
||||
"label": "Supernatural",
|
||||
"value": "supernatural"
|
||||
},
|
||||
{
|
||||
"label": "Superpower",
|
||||
"value": "superpower"
|
||||
},
|
||||
{
|
||||
"label": "Tragedy",
|
||||
"value": "tragedy"
|
||||
},
|
||||
{
|
||||
"label": "Urban",
|
||||
"value": "urban"
|
||||
},
|
||||
{
|
||||
"label": "Virtual Game",
|
||||
"value": "virtual-game"
|
||||
},
|
||||
{
|
||||
"label": "Warrior",
|
||||
"value": "warrior"
|
||||
},
|
||||
{
|
||||
"label": "Webtoons",
|
||||
"value": "webtoons"
|
||||
},
|
||||
{
|
||||
"label": "Wuxia",
|
||||
"value": "wuxia"
|
||||
},
|
||||
{
|
||||
"label": "Xianxia",
|
||||
"value": "xianxia"
|
||||
},
|
||||
{
|
||||
"label": "Xuanhuan",
|
||||
"value": "xuanhuan"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Type",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Action",
|
||||
"value": "action"
|
||||
},
|
||||
{
|
||||
"label": "Adventure",
|
||||
"value": "adventure"
|
||||
},
|
||||
{
|
||||
"label": "Chinese Novel",
|
||||
"value": "chinese-novel"
|
||||
},
|
||||
{
|
||||
"label": "Fantasy",
|
||||
"value": "fantasy"
|
||||
},
|
||||
{
|
||||
"label": "Korean Novel",
|
||||
"value": "korean-novel"
|
||||
},
|
||||
{
|
||||
"label": "Light Novel (KR)",
|
||||
"value": "light-novel-kr"
|
||||
},
|
||||
{
|
||||
"label": "Martial Arts",
|
||||
"value": "martial-arts"
|
||||
},
|
||||
{
|
||||
"label": "Published Novel (KR)",
|
||||
"value": "published-novel-kr"
|
||||
},
|
||||
{
|
||||
"label": "Romance",
|
||||
"value": "romance"
|
||||
},
|
||||
{
|
||||
"label": "Supernatural",
|
||||
"value": "supernatural"
|
||||
},
|
||||
{
|
||||
"label": "Web Novel",
|
||||
"value": "web-novel"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"type": "Picker",
|
||||
"label": "Status",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "All",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "Ongoing",
|
||||
"value": "ongoing"
|
||||
},
|
||||
{
|
||||
"label": "Hiatus",
|
||||
"value": "hiatus"
|
||||
},
|
||||
{
|
||||
"label": "Completed",
|
||||
"value": "completed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"order": {
|
||||
"type": "Picker",
|
||||
"label": "Order by",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "Default",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "A-Z",
|
||||
"value": "title"
|
||||
},
|
||||
{
|
||||
"label": "Z-A",
|
||||
"value": "titlereverse"
|
||||
},
|
||||
{
|
||||
"label": "Latest Update",
|
||||
"value": "update"
|
||||
},
|
||||
{
|
||||
"label": "Latest Added",
|
||||
"value": "latest"
|
||||
},
|
||||
{
|
||||
"label": "Popular",
|
||||
"value": "popular"
|
||||
},
|
||||
{
|
||||
"label": "Rating",
|
||||
"value": "rating"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
{
|
||||
"filters": {
|
||||
"genre[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Genre",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Action",
|
||||
"value": "action"
|
||||
},
|
||||
{
|
||||
"label": "Adult",
|
||||
"value": "adult"
|
||||
},
|
||||
{
|
||||
"label": "Adventure",
|
||||
"value": "adventure"
|
||||
},
|
||||
{
|
||||
"label": "Comedy",
|
||||
"value": "comedy"
|
||||
},
|
||||
{
|
||||
"label": "Drama",
|
||||
"value": "drama"
|
||||
},
|
||||
{
|
||||
"label": "Ecchi",
|
||||
"value": "ecchi"
|
||||
},
|
||||
{
|
||||
"label": "Fantasy",
|
||||
"value": "fantasy"
|
||||
},
|
||||
{
|
||||
"label": "Gender Bender",
|
||||
"value": "gender-bender"
|
||||
},
|
||||
{
|
||||
"label": "Harem",
|
||||
"value": "harem"
|
||||
},
|
||||
{
|
||||
"label": "Hunter",
|
||||
"value": "hunter"
|
||||
},
|
||||
{
|
||||
"label": "Martial Arts",
|
||||
"value": "martial-arts"
|
||||
},
|
||||
{
|
||||
"label": "Mature",
|
||||
"value": "mature"
|
||||
},
|
||||
{
|
||||
"label": "Modern Fantasy",
|
||||
"value": "modern-fantasy"
|
||||
},
|
||||
{
|
||||
"label": "Mystery",
|
||||
"value": "mystery"
|
||||
},
|
||||
{
|
||||
"label": "Psychological",
|
||||
"value": "psychological"
|
||||
},
|
||||
{
|
||||
"label": "Romance",
|
||||
"value": "romance"
|
||||
},
|
||||
{
|
||||
"label": "School Life",
|
||||
"value": "school-life"
|
||||
},
|
||||
{
|
||||
"label": "Seinen",
|
||||
"value": "seinen"
|
||||
},
|
||||
{
|
||||
"label": "Shounen",
|
||||
"value": "shounen"
|
||||
},
|
||||
{
|
||||
"label": "Slice of Life",
|
||||
"value": "slice-of-life"
|
||||
},
|
||||
{
|
||||
"label": "Supernatural",
|
||||
"value": "supernatural"
|
||||
},
|
||||
{
|
||||
"label": "Tragedy",
|
||||
"value": "tragedy"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Type",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Fantasy",
|
||||
"value": "fantasy"
|
||||
},
|
||||
{
|
||||
"label": "Genius",
|
||||
"value": "genius"
|
||||
},
|
||||
{
|
||||
"label": "Hunter",
|
||||
"value": "hunter"
|
||||
},
|
||||
{
|
||||
"label": "Japanese Web Novel",
|
||||
"value": "japanese-web-novel"
|
||||
},
|
||||
{
|
||||
"label": "Korean Web Novel",
|
||||
"value": "korean-web-novel"
|
||||
},
|
||||
{
|
||||
"label": "Light Novel (JP)",
|
||||
"value": "light-novel-jp"
|
||||
},
|
||||
{
|
||||
"label": "Mage",
|
||||
"value": "mage"
|
||||
},
|
||||
{
|
||||
"label": "Modern Fantasy",
|
||||
"value": "modern-fantasy"
|
||||
},
|
||||
{
|
||||
"label": "Reincarnation",
|
||||
"value": "reincarnation"
|
||||
},
|
||||
{
|
||||
"label": "Web Novel",
|
||||
"value": "web-novel"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"type": "Picker",
|
||||
"label": "Status",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "All",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "Ongoing",
|
||||
"value": "ongoing"
|
||||
},
|
||||
{
|
||||
"label": "Hiatus",
|
||||
"value": "hiatus"
|
||||
},
|
||||
{
|
||||
"label": "Completed",
|
||||
"value": "completed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"order": {
|
||||
"type": "Picker",
|
||||
"label": "Order by",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "Default",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "A-Z",
|
||||
"value": "title"
|
||||
},
|
||||
{
|
||||
"label": "Z-A",
|
||||
"value": "titlereverse"
|
||||
},
|
||||
{
|
||||
"label": "Latest Update",
|
||||
"value": "update"
|
||||
},
|
||||
{
|
||||
"label": "Latest Added",
|
||||
"value": "latest"
|
||||
},
|
||||
{
|
||||
"label": "Popular",
|
||||
"value": "popular"
|
||||
},
|
||||
{
|
||||
"label": "Rating",
|
||||
"value": "rating"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
{
|
||||
"filters": {
|
||||
"genre[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Genre",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Action",
|
||||
"value": "action"
|
||||
},
|
||||
{
|
||||
"label": "Adult",
|
||||
"value": "adult"
|
||||
},
|
||||
{
|
||||
"label": "Adventure",
|
||||
"value": "adventure"
|
||||
},
|
||||
{
|
||||
"label": "character growth",
|
||||
"value": "character-growth"
|
||||
},
|
||||
{
|
||||
"label": "Comedy",
|
||||
"value": "comedy"
|
||||
},
|
||||
{
|
||||
"label": "Dark Fantasy",
|
||||
"value": "dark-fantasy"
|
||||
},
|
||||
{
|
||||
"label": "drama",
|
||||
"value": "drama"
|
||||
},
|
||||
{
|
||||
"label": "Ecchi",
|
||||
"value": "ecchi"
|
||||
},
|
||||
{
|
||||
"label": "Fantasy",
|
||||
"value": "fantasy"
|
||||
},
|
||||
{
|
||||
"label": "Finalized",
|
||||
"value": "finalized"
|
||||
},
|
||||
{
|
||||
"label": "Fusion",
|
||||
"value": "fusion"
|
||||
},
|
||||
{
|
||||
"label": "Fusion Fantasy",
|
||||
"value": "fusion-fantasy"
|
||||
},
|
||||
{
|
||||
"label": "Game-based(LitRPG)",
|
||||
"value": "game-basedlitrpg"
|
||||
},
|
||||
{
|
||||
"label": "Girl's Love",
|
||||
"value": "girls-love"
|
||||
},
|
||||
{
|
||||
"label": "Harem",
|
||||
"value": "harem"
|
||||
},
|
||||
{
|
||||
"label": "hero",
|
||||
"value": "hero"
|
||||
},
|
||||
{
|
||||
"label": "Historical",
|
||||
"value": "historical"
|
||||
},
|
||||
{
|
||||
"label": "Horror",
|
||||
"value": "horror"
|
||||
},
|
||||
{
|
||||
"label": "Humiliation",
|
||||
"value": "humiliation"
|
||||
},
|
||||
{
|
||||
"label": "Hunter",
|
||||
"value": "hunter"
|
||||
},
|
||||
{
|
||||
"label": "Isekai",
|
||||
"value": "isekai"
|
||||
},
|
||||
{
|
||||
"label": "level up",
|
||||
"value": "level-up"
|
||||
},
|
||||
{
|
||||
"label": "Magic",
|
||||
"value": "magic"
|
||||
},
|
||||
{
|
||||
"label": "Male protagonist",
|
||||
"value": "male-protagonist"
|
||||
},
|
||||
{
|
||||
"label": "Manga",
|
||||
"value": "manga"
|
||||
},
|
||||
{
|
||||
"label": "Martial Arts",
|
||||
"value": "martial-arts"
|
||||
},
|
||||
{
|
||||
"label": "Mature",
|
||||
"value": "mature"
|
||||
},
|
||||
{
|
||||
"label": "Murim",
|
||||
"value": "murim"
|
||||
},
|
||||
{
|
||||
"label": "Mystery",
|
||||
"value": "mystery"
|
||||
},
|
||||
{
|
||||
"label": "Political Intrigue",
|
||||
"value": "political-intrigue"
|
||||
},
|
||||
{
|
||||
"label": "Psychological",
|
||||
"value": "psychological"
|
||||
},
|
||||
{
|
||||
"label": "Pure love",
|
||||
"value": "pure-love"
|
||||
},
|
||||
{
|
||||
"label": "R18",
|
||||
"value": "r18"
|
||||
},
|
||||
{
|
||||
"label": "Reincarnation",
|
||||
"value": "reincarnation"
|
||||
},
|
||||
{
|
||||
"label": "Romance",
|
||||
"value": "romance"
|
||||
},
|
||||
{
|
||||
"label": "School Life",
|
||||
"value": "school-life"
|
||||
},
|
||||
{
|
||||
"label": "Sci-fy",
|
||||
"value": "sci-fy"
|
||||
},
|
||||
{
|
||||
"label": "Seinen",
|
||||
"value": "seinen"
|
||||
},
|
||||
{
|
||||
"label": "Shounen",
|
||||
"value": "shounen"
|
||||
},
|
||||
{
|
||||
"label": "Slice of Life",
|
||||
"value": "slice-of-life"
|
||||
},
|
||||
{
|
||||
"label": "Supernatural",
|
||||
"value": "supernatural"
|
||||
},
|
||||
{
|
||||
"label": "sword",
|
||||
"value": "sword"
|
||||
},
|
||||
{
|
||||
"label": "system",
|
||||
"value": "system"
|
||||
},
|
||||
{
|
||||
"label": "Teacher",
|
||||
"value": "teacher"
|
||||
},
|
||||
{
|
||||
"label": "Thriller",
|
||||
"value": "thriller"
|
||||
},
|
||||
{
|
||||
"label": "Tragedy",
|
||||
"value": "tragedy"
|
||||
},
|
||||
{
|
||||
"label": "Transmigration",
|
||||
"value": "transmigration"
|
||||
},
|
||||
{
|
||||
"label": "Wuxia",
|
||||
"value": "wuxia"
|
||||
},
|
||||
{
|
||||
"label": "Xianxia",
|
||||
"value": "xianxia"
|
||||
},
|
||||
{
|
||||
"label": "Xuanhuan",
|
||||
"value": "xuanhuan"
|
||||
},
|
||||
{
|
||||
"label": "Yandere",
|
||||
"value": "yandere"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Type",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Chinese Novel",
|
||||
"value": "chinese-novel"
|
||||
},
|
||||
{
|
||||
"label": "Dropped",
|
||||
"value": "dropped"
|
||||
},
|
||||
{
|
||||
"label": "Web Novel",
|
||||
"value": "web-novel"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"type": "Picker",
|
||||
"label": "Status",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "All",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "Ongoing",
|
||||
"value": "ongoing"
|
||||
},
|
||||
{
|
||||
"label": "Hiatus",
|
||||
"value": "hiatus"
|
||||
},
|
||||
{
|
||||
"label": "Completed",
|
||||
"value": "completed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"order": {
|
||||
"type": "Picker",
|
||||
"label": "Order by",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "Default",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "A-Z",
|
||||
"value": "title"
|
||||
},
|
||||
{
|
||||
"label": "Z-A",
|
||||
"value": "titlereverse"
|
||||
},
|
||||
{
|
||||
"label": "Latest Update",
|
||||
"value": "update"
|
||||
},
|
||||
{
|
||||
"label": "Latest Added",
|
||||
"value": "latest"
|
||||
},
|
||||
{
|
||||
"label": "Popular",
|
||||
"value": "popular"
|
||||
},
|
||||
{
|
||||
"label": "Rating",
|
||||
"value": "rating"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"filters": {
|
||||
"genre[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Genre",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Adult",
|
||||
"value": "adult"
|
||||
},
|
||||
{
|
||||
"label": "Comedy",
|
||||
"value": "comedy"
|
||||
},
|
||||
{
|
||||
"label": "Drama",
|
||||
"value": "drama"
|
||||
},
|
||||
{
|
||||
"label": "Fantasy",
|
||||
"value": "fantasy"
|
||||
},
|
||||
{
|
||||
"label": "Historical",
|
||||
"value": "historical"
|
||||
},
|
||||
{
|
||||
"label": "Josei",
|
||||
"value": "josei"
|
||||
},
|
||||
{
|
||||
"label": "Mature",
|
||||
"value": "mature"
|
||||
},
|
||||
{
|
||||
"label": "Modern",
|
||||
"value": "modern"
|
||||
},
|
||||
{
|
||||
"label": "Romance",
|
||||
"value": "romance"
|
||||
},
|
||||
{
|
||||
"label": "Smut",
|
||||
"value": "smut"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Type",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Web Novel",
|
||||
"value": "web-novel"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"type": "Picker",
|
||||
"label": "Status",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "All",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "Ongoing",
|
||||
"value": "ongoing"
|
||||
},
|
||||
{
|
||||
"label": "Hiatus",
|
||||
"value": "hiatus"
|
||||
},
|
||||
{
|
||||
"label": "Completed",
|
||||
"value": "completed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"order": {
|
||||
"type": "Picker",
|
||||
"label": "Order by",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "Default",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "A-Z",
|
||||
"value": "title"
|
||||
},
|
||||
{
|
||||
"label": "Z-A",
|
||||
"value": "titlereverse"
|
||||
},
|
||||
{
|
||||
"label": "Latest Update",
|
||||
"value": "update"
|
||||
},
|
||||
{
|
||||
"label": "Latest Added",
|
||||
"value": "latest"
|
||||
},
|
||||
{
|
||||
"label": "Popular",
|
||||
"value": "popular"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"filters": {
|
||||
"genre[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Genre",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Adult",
|
||||
"value": "adult"
|
||||
},
|
||||
{
|
||||
"label": "Comedy",
|
||||
"value": "comedy"
|
||||
},
|
||||
{
|
||||
"label": "Drama",
|
||||
"value": "drama"
|
||||
},
|
||||
{
|
||||
"label": "Fantasy",
|
||||
"value": "fantasy"
|
||||
},
|
||||
{
|
||||
"label": "Historical",
|
||||
"value": "historical"
|
||||
},
|
||||
{
|
||||
"label": "Josei",
|
||||
"value": "josei"
|
||||
},
|
||||
{
|
||||
"label": "Mature",
|
||||
"value": "mature"
|
||||
},
|
||||
{
|
||||
"label": "Modern",
|
||||
"value": "modern"
|
||||
},
|
||||
{
|
||||
"label": "Romance",
|
||||
"value": "romance"
|
||||
},
|
||||
{
|
||||
"label": "Smut",
|
||||
"value": "smut"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Type",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Web Novel",
|
||||
"value": "web-novel"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"type": "Picker",
|
||||
"label": "Status",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "All",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "Ongoing",
|
||||
"value": "ongoing"
|
||||
},
|
||||
{
|
||||
"label": "Hiatus",
|
||||
"value": "hiatus"
|
||||
},
|
||||
{
|
||||
"label": "Completed",
|
||||
"value": "completed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"order": {
|
||||
"type": "Picker",
|
||||
"label": "Order by",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "Default",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "A-Z",
|
||||
"value": "title"
|
||||
},
|
||||
{
|
||||
"label": "Z-A",
|
||||
"value": "titlereverse"
|
||||
},
|
||||
{
|
||||
"label": "Latest Update",
|
||||
"value": "update"
|
||||
},
|
||||
{
|
||||
"label": "Latest Added",
|
||||
"value": "latest"
|
||||
},
|
||||
{
|
||||
"label": "Popular",
|
||||
"value": "popular"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,200 @@
|
||||
{
|
||||
"filters": {
|
||||
"genre[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Kategori",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Aksiyon",
|
||||
"value": "aksiyon"
|
||||
},
|
||||
{
|
||||
"label": "Büyü",
|
||||
"value": "buyu"
|
||||
},
|
||||
{
|
||||
"label": "Doğa Üstü",
|
||||
"value": "doga-ustu"
|
||||
},
|
||||
{
|
||||
"label": "Doğaüstü",
|
||||
"value": "dogaustu"
|
||||
},
|
||||
{
|
||||
"label": "Dövüş Sanatları",
|
||||
"value": "dovus-sanatlari"
|
||||
},
|
||||
{
|
||||
"label": "Dram",
|
||||
"value": "dram"
|
||||
},
|
||||
{
|
||||
"label": "Fantastik",
|
||||
"value": "fantastik"
|
||||
},
|
||||
{
|
||||
"label": "Geriye Dönüş",
|
||||
"value": "geriye-donus"
|
||||
},
|
||||
{
|
||||
"label": "Gizem",
|
||||
"value": "gizem"
|
||||
},
|
||||
{
|
||||
"label": "Kara Büyü",
|
||||
"value": "kara-buyu"
|
||||
},
|
||||
{
|
||||
"label": "Korku",
|
||||
"value": "korku"
|
||||
},
|
||||
{
|
||||
"label": "Macera",
|
||||
"value": "macera"
|
||||
},
|
||||
{
|
||||
"label": "Murim",
|
||||
"value": "murim"
|
||||
},
|
||||
{
|
||||
"label": "Okul",
|
||||
"value": "okul"
|
||||
},
|
||||
{
|
||||
"label": "Psikolojik",
|
||||
"value": "psikolojik"
|
||||
},
|
||||
{
|
||||
"label": "Romantizm",
|
||||
"value": "romantizm"
|
||||
},
|
||||
{
|
||||
"label": "Seinen",
|
||||
"value": "seinen"
|
||||
},
|
||||
{
|
||||
"label": "Shounen",
|
||||
"value": "shounen"
|
||||
},
|
||||
{
|
||||
"label": "Trajedi",
|
||||
"value": "trajedi"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Tür",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Akademi",
|
||||
"value": "akademi"
|
||||
},
|
||||
{
|
||||
"label": "Aksiyon",
|
||||
"value": "aksiyon"
|
||||
},
|
||||
{
|
||||
"label": "Dahi",
|
||||
"value": "dahi"
|
||||
},
|
||||
{
|
||||
"label": "Dövüş Sanatları",
|
||||
"value": "dovus-sanatlari"
|
||||
},
|
||||
{
|
||||
"label": "Element",
|
||||
"value": "element"
|
||||
},
|
||||
{
|
||||
"label": "Fantastik",
|
||||
"value": "fantastik"
|
||||
},
|
||||
{
|
||||
"label": "Geriye Dönüş",
|
||||
"value": "geriye-donus"
|
||||
},
|
||||
{
|
||||
"label": "Güçlü Ana Karakter",
|
||||
"value": "guclu-ana-karakter"
|
||||
},
|
||||
{
|
||||
"label": "Kılıç Ustası",
|
||||
"value": "kilic-ustasi"
|
||||
},
|
||||
{
|
||||
"label": "Savaşçı",
|
||||
"value": "savasci"
|
||||
},
|
||||
{
|
||||
"label": "Yaratıklar",
|
||||
"value": "yaratiklar"
|
||||
},
|
||||
{
|
||||
"label": "Zaman Döngüsü",
|
||||
"value": "zaman-dongusu"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"type": "Picker",
|
||||
"label": "Durum",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "Hepsi",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "Ongoing",
|
||||
"value": "ongoing"
|
||||
},
|
||||
{
|
||||
"label": "Hiatus",
|
||||
"value": "hiatus"
|
||||
},
|
||||
{
|
||||
"label": "Completed",
|
||||
"value": "completed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"order": {
|
||||
"type": "Picker",
|
||||
"label": "Sırala",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "Temizle",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "A-Z",
|
||||
"value": "title"
|
||||
},
|
||||
{
|
||||
"label": "Z-A",
|
||||
"value": "titlereverse"
|
||||
},
|
||||
{
|
||||
"label": "Son Güncellenenler",
|
||||
"value": "update"
|
||||
},
|
||||
{
|
||||
"label": "Yeni Eklenenler",
|
||||
"value": "latest"
|
||||
},
|
||||
{
|
||||
"label": "Popüler",
|
||||
"value": "popular"
|
||||
},
|
||||
{
|
||||
"label": "Puan",
|
||||
"value": "rating"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
{
|
||||
"filters": {
|
||||
"genre[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "تصنيف",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Romance",
|
||||
"value": "romance"
|
||||
},
|
||||
{
|
||||
"label": "Shounen Ai",
|
||||
"value": "shounen-ai"
|
||||
},
|
||||
{
|
||||
"label": "Wuxia",
|
||||
"value": "wuxia"
|
||||
},
|
||||
{
|
||||
"label": "Xianxia",
|
||||
"value": "xianxia"
|
||||
},
|
||||
{
|
||||
"label": "XUANHUAN",
|
||||
"value": "xuanhuan"
|
||||
},
|
||||
{
|
||||
"label": "أبطال خارقين",
|
||||
"value": "أبطال-خارقين"
|
||||
},
|
||||
{
|
||||
"label": "أساطير",
|
||||
"value": "أساطير"
|
||||
},
|
||||
{
|
||||
"label": "أشباح",
|
||||
"value": "أشباح"
|
||||
},
|
||||
{
|
||||
"label": "أكشن",
|
||||
"value": "action"
|
||||
},
|
||||
{
|
||||
"label": "ألعاب",
|
||||
"value": "ألعاب"
|
||||
},
|
||||
{
|
||||
"label": "إثارة",
|
||||
"value": "excitement"
|
||||
},
|
||||
{
|
||||
"label": "إسلامي",
|
||||
"value": "إسلامي"
|
||||
},
|
||||
{
|
||||
"label": "إنتقال الى عالم أخر",
|
||||
"value": "isekai"
|
||||
},
|
||||
{
|
||||
"label": "إيتشي",
|
||||
"value": "etchi"
|
||||
},
|
||||
{
|
||||
"label": "اكاديمي",
|
||||
"value": "اكاديمي"
|
||||
},
|
||||
{
|
||||
"label": "اكشن",
|
||||
"value": "اكشن"
|
||||
},
|
||||
{
|
||||
"label": "الإثارة",
|
||||
"value": "الإثارة"
|
||||
},
|
||||
{
|
||||
"label": "الخيال العلمي",
|
||||
"value": "sci-fi"
|
||||
},
|
||||
{
|
||||
"label": "الدراما",
|
||||
"value": "الدراما"
|
||||
},
|
||||
{
|
||||
"label": "المغامرات",
|
||||
"value": "المغامرات"
|
||||
},
|
||||
{
|
||||
"label": "انتقام",
|
||||
"value": "انتقام"
|
||||
},
|
||||
{
|
||||
"label": "بطل مضاد",
|
||||
"value": "بطل-مضاد"
|
||||
},
|
||||
{
|
||||
"label": "بطل ناضج",
|
||||
"value": "بطل-ناضج"
|
||||
},
|
||||
{
|
||||
"label": "بقاء",
|
||||
"value": "بقاء"
|
||||
},
|
||||
{
|
||||
"label": "بناء مملكة",
|
||||
"value": "بناء-مملكة"
|
||||
},
|
||||
{
|
||||
"label": "بوليسي",
|
||||
"value": "policy"
|
||||
},
|
||||
{
|
||||
"label": "تاريخ",
|
||||
"value": "تاريخ"
|
||||
},
|
||||
{
|
||||
"label": "تاريخي",
|
||||
"value": "historical"
|
||||
},
|
||||
{
|
||||
"label": "تحقيقات",
|
||||
"value": "تحقيق"
|
||||
},
|
||||
{
|
||||
"label": "تشويق",
|
||||
"value": "تشويق"
|
||||
},
|
||||
{
|
||||
"label": "تقمص شخصيات",
|
||||
"value": "rpg"
|
||||
},
|
||||
{
|
||||
"label": "تلاعب",
|
||||
"value": "تلاعب"
|
||||
},
|
||||
{
|
||||
"label": "تناسخ",
|
||||
"value": "تناسخ"
|
||||
},
|
||||
{
|
||||
"label": "جريمة",
|
||||
"value": "crime"
|
||||
},
|
||||
{
|
||||
"label": "جوسى",
|
||||
"value": "josei"
|
||||
},
|
||||
{
|
||||
"label": "جوسي",
|
||||
"value": "جوسي"
|
||||
},
|
||||
{
|
||||
"label": "حريم",
|
||||
"value": "harem"
|
||||
},
|
||||
{
|
||||
"label": "حل الألغاز",
|
||||
"value": "حل-الألغاز"
|
||||
},
|
||||
{
|
||||
"label": "حياة مدرسية",
|
||||
"value": "school-life"
|
||||
},
|
||||
{
|
||||
"label": "خارق للطبيعة",
|
||||
"value": "خارق-للطبيعة"
|
||||
},
|
||||
{
|
||||
"label": "خيال",
|
||||
"value": "خيال"
|
||||
},
|
||||
{
|
||||
"label": "خيال علمي",
|
||||
"value": "خيال-علمي"
|
||||
},
|
||||
{
|
||||
"label": "خيالي",
|
||||
"value": "خيالي"
|
||||
},
|
||||
{
|
||||
"label": "خيالي(فانتازيا)",
|
||||
"value": "fantasy"
|
||||
},
|
||||
{
|
||||
"label": "دراما",
|
||||
"value": "drama"
|
||||
},
|
||||
{
|
||||
"label": "درامي",
|
||||
"value": "درامي"
|
||||
},
|
||||
{
|
||||
"label": "رعب",
|
||||
"value": "horror"
|
||||
},
|
||||
{
|
||||
"label": "رعب كوني",
|
||||
"value": "رعب-كوني"
|
||||
},
|
||||
{
|
||||
"label": "رعب نفسي",
|
||||
"value": "رعب-نفسي"
|
||||
},
|
||||
{
|
||||
"label": "رومانسي",
|
||||
"value": "romantic"
|
||||
},
|
||||
{
|
||||
"label": "رومانسية",
|
||||
"value": "رومانسية"
|
||||
},
|
||||
{
|
||||
"label": "رومنسية",
|
||||
"value": "رومنسية"
|
||||
},
|
||||
{
|
||||
"label": "زنزانة",
|
||||
"value": "زنزانة"
|
||||
},
|
||||
{
|
||||
"label": "زيانشيا",
|
||||
"value": "زيانشيا"
|
||||
},
|
||||
{
|
||||
"label": "ستيم بانك",
|
||||
"value": "ستيم-بانك"
|
||||
},
|
||||
{
|
||||
"label": "سحر",
|
||||
"value": "magic"
|
||||
},
|
||||
{
|
||||
"label": "سفر بالزمن",
|
||||
"value": "سفر-بالزمن"
|
||||
},
|
||||
{
|
||||
"label": "سفر عبر الزمن",
|
||||
"value": "سفر-عبر-الزمن"
|
||||
},
|
||||
{
|
||||
"label": "سياسة",
|
||||
"value": "سياسة"
|
||||
},
|
||||
{
|
||||
"label": "سينن",
|
||||
"value": "senen"
|
||||
},
|
||||
{
|
||||
"label": "شريحة من الحياة",
|
||||
"value": "slice-of-life"
|
||||
},
|
||||
{
|
||||
"label": "شعر",
|
||||
"value": "شعر"
|
||||
},
|
||||
{
|
||||
"label": "شوانهان",
|
||||
"value": "شوانهان"
|
||||
},
|
||||
{
|
||||
"label": "شوانهوان",
|
||||
"value": "شوانهوان"
|
||||
},
|
||||
{
|
||||
"label": "شوجو",
|
||||
"value": "shojo"
|
||||
},
|
||||
{
|
||||
"label": "شونين",
|
||||
"value": "shonen"
|
||||
},
|
||||
{
|
||||
"label": "شيانشيا",
|
||||
"value": "شيانشيا"
|
||||
},
|
||||
{
|
||||
"label": "طبي",
|
||||
"value": "medical"
|
||||
},
|
||||
{
|
||||
"label": "ظواهر خارقة للطبيعة",
|
||||
"value": "supernatural"
|
||||
},
|
||||
{
|
||||
"label": "عائلي",
|
||||
"value": "عائلي"
|
||||
},
|
||||
{
|
||||
"label": "عموض",
|
||||
"value": "عموض"
|
||||
},
|
||||
{
|
||||
"label": "غموض",
|
||||
"value": "mysteries"
|
||||
},
|
||||
{
|
||||
"label": "فانتازي",
|
||||
"value": "فانتازي"
|
||||
},
|
||||
{
|
||||
"label": "فانتازيا",
|
||||
"value": "فانتازيا"
|
||||
},
|
||||
{
|
||||
"label": "فانفيك",
|
||||
"value": "فانفيك"
|
||||
},
|
||||
{
|
||||
"label": "فنتازيا",
|
||||
"value": "فنتازيا"
|
||||
},
|
||||
{
|
||||
"label": "فنون القتال",
|
||||
"value": "martial-arts"
|
||||
},
|
||||
{
|
||||
"label": "فنون قتال",
|
||||
"value": "فنون-قتال"
|
||||
},
|
||||
{
|
||||
"label": "قصة قصيرة",
|
||||
"value": "قصة-قصيرة"
|
||||
},
|
||||
{
|
||||
"label": "قوة خارقة",
|
||||
"value": "قوة-خارقة"
|
||||
},
|
||||
{
|
||||
"label": "قوى خارقة",
|
||||
"value": "superpower"
|
||||
},
|
||||
{
|
||||
"label": "كوميدي",
|
||||
"value": "comedy"
|
||||
},
|
||||
{
|
||||
"label": "كوميديا",
|
||||
"value": "كوميديا"
|
||||
},
|
||||
{
|
||||
"label": "كوميدية",
|
||||
"value": "كوميدية"
|
||||
},
|
||||
{
|
||||
"label": "مأسأة",
|
||||
"value": "مأسأة"
|
||||
},
|
||||
{
|
||||
"label": "مأساة",
|
||||
"value": "مأساة"
|
||||
},
|
||||
{
|
||||
"label": "مأساوي",
|
||||
"value": "tragedy"
|
||||
},
|
||||
{
|
||||
"label": "مؤامرة",
|
||||
"value": "مؤامرة"
|
||||
},
|
||||
{
|
||||
"label": "ما بعد الكارثة",
|
||||
"value": "after-the-disaster"
|
||||
},
|
||||
{
|
||||
"label": "ما بعد نهاية العالم",
|
||||
"value": "ما-بعد-نهاية-العالم"
|
||||
},
|
||||
{
|
||||
"label": "مضاد البطل",
|
||||
"value": "مضاد-البطل"
|
||||
},
|
||||
{
|
||||
"label": "مغامرات",
|
||||
"value": "مغامرات"
|
||||
},
|
||||
{
|
||||
"label": "مغامرة",
|
||||
"value": "adventure"
|
||||
},
|
||||
{
|
||||
"label": "ميكا",
|
||||
"value": "mechanical"
|
||||
},
|
||||
{
|
||||
"label": "ناضج",
|
||||
"value": "mature"
|
||||
},
|
||||
{
|
||||
"label": "نظام",
|
||||
"value": "نظام"
|
||||
},
|
||||
{
|
||||
"label": "نفسي",
|
||||
"value": "psychological"
|
||||
},
|
||||
{
|
||||
"label": "ون شوت",
|
||||
"value": "ون-شوت"
|
||||
},
|
||||
{
|
||||
"label": "ووشيا",
|
||||
"value": "ووشيا"
|
||||
},
|
||||
{
|
||||
"label": "ووكسيا",
|
||||
"value": "ووكسيا"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "النوع",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "إنجليزية",
|
||||
"value": "english"
|
||||
},
|
||||
{
|
||||
"label": "ترجمة إحترافية",
|
||||
"value": "ترجمة-إحترافية"
|
||||
},
|
||||
{
|
||||
"label": "رواية لايت",
|
||||
"value": "light-novel"
|
||||
},
|
||||
{
|
||||
"label": "رواية مؤلفة",
|
||||
"value": "رواية-مؤلفة"
|
||||
},
|
||||
{
|
||||
"label": "رواية مترجمة",
|
||||
"value": "رواية-مترجمة"
|
||||
},
|
||||
{
|
||||
"label": "رواية ويب",
|
||||
"value": "web-novel"
|
||||
},
|
||||
{
|
||||
"label": "صينية",
|
||||
"value": "chinese"
|
||||
},
|
||||
{
|
||||
"label": "عربية",
|
||||
"value": "arabic"
|
||||
},
|
||||
{
|
||||
"label": "كورية",
|
||||
"value": "korean"
|
||||
},
|
||||
{
|
||||
"label": "مؤلفة",
|
||||
"value": "مؤلفة"
|
||||
},
|
||||
{
|
||||
"label": "ون شوت",
|
||||
"value": "ون-شوت"
|
||||
},
|
||||
{
|
||||
"label": "يابانية",
|
||||
"value": "japanese"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"type": "Picker",
|
||||
"label": "الحالة",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "الكل",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "Ongoing",
|
||||
"value": "ongoing"
|
||||
},
|
||||
{
|
||||
"label": "Hiatus",
|
||||
"value": "hiatus"
|
||||
},
|
||||
{
|
||||
"label": "Completed",
|
||||
"value": "completed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"order": {
|
||||
"type": "Picker",
|
||||
"label": "ترتيب حسب",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "الإعداد الأولي",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "A-Z",
|
||||
"value": "title"
|
||||
},
|
||||
{
|
||||
"label": "Z-A",
|
||||
"value": "titlereverse"
|
||||
},
|
||||
{
|
||||
"label": "أخر التحديثات",
|
||||
"value": "update"
|
||||
},
|
||||
{
|
||||
"label": "أخر ما تم إضافته",
|
||||
"value": "latest"
|
||||
},
|
||||
{
|
||||
"label": "الرائجة",
|
||||
"value": "popular"
|
||||
},
|
||||
{
|
||||
"label": "التقييم",
|
||||
"value": "rating"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
{
|
||||
"filters": {
|
||||
"genre[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Genre",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Action",
|
||||
"value": "action"
|
||||
},
|
||||
{
|
||||
"label": "Adult",
|
||||
"value": "adult"
|
||||
},
|
||||
{
|
||||
"label": "Adventure",
|
||||
"value": "adventure"
|
||||
},
|
||||
{
|
||||
"label": "BL",
|
||||
"value": "bl"
|
||||
},
|
||||
{
|
||||
"label": "Comedy",
|
||||
"value": "comedy"
|
||||
},
|
||||
{
|
||||
"label": "Drama",
|
||||
"value": "drama"
|
||||
},
|
||||
{
|
||||
"label": "Fantasy",
|
||||
"value": "fantasy"
|
||||
},
|
||||
{
|
||||
"label": "Gender Bender",
|
||||
"value": "gender-bender"
|
||||
},
|
||||
{
|
||||
"label": "Harem",
|
||||
"value": "harem"
|
||||
},
|
||||
{
|
||||
"label": "Historical",
|
||||
"value": "historical"
|
||||
},
|
||||
{
|
||||
"label": "Horror",
|
||||
"value": "horror"
|
||||
},
|
||||
{
|
||||
"label": "Josei",
|
||||
"value": "josei"
|
||||
},
|
||||
{
|
||||
"label": "Martial Arts",
|
||||
"value": "martial-arts"
|
||||
},
|
||||
{
|
||||
"label": "Mature",
|
||||
"value": "mature"
|
||||
},
|
||||
{
|
||||
"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": "Shoujo",
|
||||
"value": "shoujo"
|
||||
},
|
||||
{
|
||||
"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": "Tragedy",
|
||||
"value": "tragedy"
|
||||
},
|
||||
{
|
||||
"label": "Wuxia",
|
||||
"value": "wuxia"
|
||||
},
|
||||
{
|
||||
"label": "Xianxia",
|
||||
"value": "xianxia"
|
||||
},
|
||||
{
|
||||
"label": "Yaoi",
|
||||
"value": "yaoi"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Type",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Chinese Novel",
|
||||
"value": "chinese-novel"
|
||||
},
|
||||
{
|
||||
"label": "Web Novel",
|
||||
"value": "web-novel"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"type": "Picker",
|
||||
"label": "Status",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "All",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "Ongoing",
|
||||
"value": "ongoing"
|
||||
},
|
||||
{
|
||||
"label": "Hiatus",
|
||||
"value": "hiatus"
|
||||
},
|
||||
{
|
||||
"label": "Completed",
|
||||
"value": "completed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"order": {
|
||||
"type": "Picker",
|
||||
"label": "Order by",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "Default",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "A-Z",
|
||||
"value": "title"
|
||||
},
|
||||
{
|
||||
"label": "Z-A",
|
||||
"value": "titlereverse"
|
||||
},
|
||||
{
|
||||
"label": "Latest Update",
|
||||
"value": "update"
|
||||
},
|
||||
{
|
||||
"label": "Latest Added",
|
||||
"value": "latest"
|
||||
},
|
||||
{
|
||||
"label": "Popular",
|
||||
"value": "popular"
|
||||
},
|
||||
{
|
||||
"label": "Rating",
|
||||
"value": "rating"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
{
|
||||
"filters": {
|
||||
"genre[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Genre",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "+18",
|
||||
"value": "18"
|
||||
},
|
||||
{
|
||||
"label": "Ação",
|
||||
"value": "acao"
|
||||
},
|
||||
{
|
||||
"label": "Artes Marciais",
|
||||
"value": "artes-marciais"
|
||||
},
|
||||
{
|
||||
"label": "Aventura",
|
||||
"value": "aventura"
|
||||
},
|
||||
{
|
||||
"label": "Comédia",
|
||||
"value": "comedia"
|
||||
},
|
||||
{
|
||||
"label": "Cultivo",
|
||||
"value": "cultivo"
|
||||
},
|
||||
{
|
||||
"label": "Cyberpunk",
|
||||
"value": "cyberpunk"
|
||||
},
|
||||
{
|
||||
"label": "Drama",
|
||||
"value": "drama"
|
||||
},
|
||||
{
|
||||
"label": "Ecchi",
|
||||
"value": "ecchi"
|
||||
},
|
||||
{
|
||||
"label": "Esportes",
|
||||
"value": "esportes"
|
||||
},
|
||||
{
|
||||
"label": "Fanfiction",
|
||||
"value": "fanfiction"
|
||||
},
|
||||
{
|
||||
"label": "Fantasia",
|
||||
"value": "fantasia"
|
||||
},
|
||||
{
|
||||
"label": "Ficção Científica",
|
||||
"value": "ficcao-cientifica"
|
||||
},
|
||||
{
|
||||
"label": "Games",
|
||||
"value": "games"
|
||||
},
|
||||
{
|
||||
"label": "Harem",
|
||||
"value": "harem"
|
||||
},
|
||||
{
|
||||
"label": "Horror",
|
||||
"value": "horror"
|
||||
},
|
||||
{
|
||||
"label": "Isekai",
|
||||
"value": "isekai"
|
||||
},
|
||||
{
|
||||
"label": "Magia",
|
||||
"value": "magia"
|
||||
},
|
||||
{
|
||||
"label": "Mecha",
|
||||
"value": "mecha"
|
||||
},
|
||||
{
|
||||
"label": "Militar",
|
||||
"value": "militar"
|
||||
},
|
||||
{
|
||||
"label": "Mistério",
|
||||
"value": "misterio"
|
||||
},
|
||||
{
|
||||
"label": "Novel Nacional",
|
||||
"value": "novel-nacional"
|
||||
},
|
||||
{
|
||||
"label": "Psicológico",
|
||||
"value": "psicologico"
|
||||
},
|
||||
{
|
||||
"label": "Romance",
|
||||
"value": "romance"
|
||||
},
|
||||
{
|
||||
"label": "Sci-fi",
|
||||
"value": "sci-fi"
|
||||
},
|
||||
{
|
||||
"label": "Seinen",
|
||||
"value": "seinen"
|
||||
},
|
||||
{
|
||||
"label": "Shoujo",
|
||||
"value": "shoujo"
|
||||
},
|
||||
{
|
||||
"label": "Shounen",
|
||||
"value": "shounen"
|
||||
},
|
||||
{
|
||||
"label": "Shounen BL",
|
||||
"value": "shounen-bl"
|
||||
},
|
||||
{
|
||||
"label": "Slice of Life",
|
||||
"value": "slice-of-life"
|
||||
},
|
||||
{
|
||||
"label": "Sobrenatural",
|
||||
"value": "sobrenatural"
|
||||
},
|
||||
{
|
||||
"label": "Terror",
|
||||
"value": "terror"
|
||||
},
|
||||
{
|
||||
"label": "Tragédia",
|
||||
"value": "tragedia"
|
||||
},
|
||||
{
|
||||
"label": "Vida Escolar",
|
||||
"value": "vida-escolar"
|
||||
},
|
||||
{
|
||||
"label": "Wuxia",
|
||||
"value": "wuxia"
|
||||
},
|
||||
{
|
||||
"label": "Xianxia",
|
||||
"value": "xianxia"
|
||||
},
|
||||
{
|
||||
"label": "Xuanhuan",
|
||||
"value": "xuanhuan"
|
||||
},
|
||||
{
|
||||
"label": "Yaoi",
|
||||
"value": "yaoi"
|
||||
},
|
||||
{
|
||||
"label": "Yuri",
|
||||
"value": "yuri"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Tipo",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Light Novel",
|
||||
"value": "light-novel"
|
||||
},
|
||||
{
|
||||
"label": "Livro",
|
||||
"value": "livro"
|
||||
},
|
||||
{
|
||||
"label": "One-Shot",
|
||||
"value": "one-shot"
|
||||
},
|
||||
{
|
||||
"label": "Web Novel",
|
||||
"value": "web-novel"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"type": "Picker",
|
||||
"label": "Status",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "Tudo",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "Ongoing",
|
||||
"value": "ongoing"
|
||||
},
|
||||
{
|
||||
"label": "Hiatus",
|
||||
"value": "hiatus"
|
||||
},
|
||||
{
|
||||
"label": "Completed",
|
||||
"value": "completed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"order": {
|
||||
"type": "Picker",
|
||||
"label": "Ordenar por",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "Padrão",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "A-Z",
|
||||
"value": "title"
|
||||
},
|
||||
{
|
||||
"label": "Z-A",
|
||||
"value": "titlereverse"
|
||||
},
|
||||
{
|
||||
"label": "Últimos Lançamentos",
|
||||
"value": "update"
|
||||
},
|
||||
{
|
||||
"label": "Última Adição",
|
||||
"value": "latest"
|
||||
},
|
||||
{
|
||||
"label": "Popular",
|
||||
"value": "popular"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
{
|
||||
"filters": {
|
||||
"genre[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Genre",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "action",
|
||||
"value": "action"
|
||||
},
|
||||
{
|
||||
"label": "alchimie",
|
||||
"value": "alchimie"
|
||||
},
|
||||
{
|
||||
"label": "Arts martiaux",
|
||||
"value": "arts-martiaux"
|
||||
},
|
||||
{
|
||||
"label": "Aventure",
|
||||
"value": "aventure"
|
||||
},
|
||||
{
|
||||
"label": "Célébrités",
|
||||
"value": "celebrites"
|
||||
},
|
||||
{
|
||||
"label": "chinese",
|
||||
"value": "chinese"
|
||||
},
|
||||
{
|
||||
"label": "Combat",
|
||||
"value": "combat"
|
||||
},
|
||||
{
|
||||
"label": "comedie",
|
||||
"value": "comedie"
|
||||
},
|
||||
{
|
||||
"label": "Cultivation",
|
||||
"value": "cultivation"
|
||||
},
|
||||
{
|
||||
"label": "dark fantasy",
|
||||
"value": "dark-fantasy"
|
||||
},
|
||||
{
|
||||
"label": "Demon",
|
||||
"value": "demon"
|
||||
},
|
||||
{
|
||||
"label": "Drame",
|
||||
"value": "drame"
|
||||
},
|
||||
{
|
||||
"label": "Fantasie",
|
||||
"value": "fantasie"
|
||||
},
|
||||
{
|
||||
"label": "Fantastique",
|
||||
"value": "fantastique"
|
||||
},
|
||||
{
|
||||
"label": "Guildes",
|
||||
"value": "guildes"
|
||||
},
|
||||
{
|
||||
"label": "harem",
|
||||
"value": "harem"
|
||||
},
|
||||
{
|
||||
"label": "Idole",
|
||||
"value": "idole"
|
||||
},
|
||||
{
|
||||
"label": "japan",
|
||||
"value": "japan"
|
||||
},
|
||||
{
|
||||
"label": "jeux vidéos",
|
||||
"value": "jeux-videos"
|
||||
},
|
||||
{
|
||||
"label": "Josei",
|
||||
"value": "josei"
|
||||
},
|
||||
{
|
||||
"label": "korean",
|
||||
"value": "korean"
|
||||
},
|
||||
{
|
||||
"label": "light Novel",
|
||||
"value": "light-novel"
|
||||
},
|
||||
{
|
||||
"label": "Magic",
|
||||
"value": "magic"
|
||||
},
|
||||
{
|
||||
"label": "mature",
|
||||
"value": "mature"
|
||||
},
|
||||
{
|
||||
"label": "Mystère",
|
||||
"value": "mystere"
|
||||
},
|
||||
{
|
||||
"label": "novel",
|
||||
"value": "novel"
|
||||
},
|
||||
{
|
||||
"label": "Renaissance",
|
||||
"value": "renaissance"
|
||||
},
|
||||
{
|
||||
"label": "Roman",
|
||||
"value": "roman"
|
||||
},
|
||||
{
|
||||
"label": "romance",
|
||||
"value": "romance"
|
||||
},
|
||||
{
|
||||
"label": "School life",
|
||||
"value": "school-life"
|
||||
},
|
||||
{
|
||||
"label": "Science-fiction",
|
||||
"value": "science-fiction"
|
||||
},
|
||||
{
|
||||
"label": "Seinen",
|
||||
"value": "seinen"
|
||||
},
|
||||
{
|
||||
"label": "Shonen",
|
||||
"value": "shonen"
|
||||
},
|
||||
{
|
||||
"label": "showbiz",
|
||||
"value": "showbiz"
|
||||
},
|
||||
{
|
||||
"label": "Surnaturel",
|
||||
"value": "surnaturel"
|
||||
},
|
||||
{
|
||||
"label": "système",
|
||||
"value": "systeme"
|
||||
},
|
||||
{
|
||||
"label": "Système de niveau",
|
||||
"value": "systeme-de-niveau"
|
||||
},
|
||||
{
|
||||
"label": "tranche de vie",
|
||||
"value": "tranche-de-vie"
|
||||
},
|
||||
{
|
||||
"label": "webnovel",
|
||||
"value": "webnovel"
|
||||
},
|
||||
{
|
||||
"label": "xp",
|
||||
"value": "xp"
|
||||
},
|
||||
{
|
||||
"label": "Xuanhuan",
|
||||
"value": "xuanhuan"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Type",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Korean",
|
||||
"value": "korean"
|
||||
},
|
||||
{
|
||||
"label": "Light novel",
|
||||
"value": "light-novel"
|
||||
},
|
||||
{
|
||||
"label": "novel chinois",
|
||||
"value": "novel-chinois"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"type": "Picker",
|
||||
"label": "Statut",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "Tout",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "Ongoing",
|
||||
"value": "ongoing"
|
||||
},
|
||||
{
|
||||
"label": "Hiatus",
|
||||
"value": "hiatus"
|
||||
},
|
||||
{
|
||||
"label": "Completed",
|
||||
"value": "completed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"order": {
|
||||
"type": "Picker",
|
||||
"label": "Par ordre",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "Par défaut",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "A-Z",
|
||||
"value": "title"
|
||||
},
|
||||
{
|
||||
"label": "Z-A",
|
||||
"value": "titlereverse"
|
||||
},
|
||||
{
|
||||
"label": "Dernier mise à jour",
|
||||
"value": "update"
|
||||
},
|
||||
{
|
||||
"label": "Derniers Ajouts",
|
||||
"value": "latest"
|
||||
},
|
||||
{
|
||||
"label": "Populaire",
|
||||
"value": "popular"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
{
|
||||
"filters": {
|
||||
"genre[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Genre",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Action",
|
||||
"value": "action"
|
||||
},
|
||||
{
|
||||
"label": "Adult",
|
||||
"value": "adult"
|
||||
},
|
||||
{
|
||||
"label": "Adventure",
|
||||
"value": "adventure"
|
||||
},
|
||||
{
|
||||
"label": "Apocalypse",
|
||||
"value": "apocalypse"
|
||||
},
|
||||
{
|
||||
"label": "Childcare",
|
||||
"value": "childcare"
|
||||
},
|
||||
{
|
||||
"label": "Comedy",
|
||||
"value": "comedy"
|
||||
},
|
||||
{
|
||||
"label": "Cultivation",
|
||||
"value": "cultivation"
|
||||
},
|
||||
{
|
||||
"label": "Drama",
|
||||
"value": "drama"
|
||||
},
|
||||
{
|
||||
"label": "Eastern",
|
||||
"value": "eastern"
|
||||
},
|
||||
{
|
||||
"label": "Entertainment",
|
||||
"value": "entertainment"
|
||||
},
|
||||
{
|
||||
"label": "Fantasy",
|
||||
"value": "fantasy"
|
||||
},
|
||||
{
|
||||
"label": "Gender Bender",
|
||||
"value": "gender-bender"
|
||||
},
|
||||
{
|
||||
"label": "Harem",
|
||||
"value": "harem"
|
||||
},
|
||||
{
|
||||
"label": "Historical",
|
||||
"value": "historical"
|
||||
},
|
||||
{
|
||||
"label": "Horror",
|
||||
"value": "horror"
|
||||
},
|
||||
{
|
||||
"label": "Idol",
|
||||
"value": "idol"
|
||||
},
|
||||
{
|
||||
"label": "Josei",
|
||||
"value": "josei"
|
||||
},
|
||||
{
|
||||
"label": "Kindergarten",
|
||||
"value": "kindergarten"
|
||||
},
|
||||
{
|
||||
"label": "Korean",
|
||||
"value": "korean"
|
||||
},
|
||||
{
|
||||
"label": "Magic Swordman",
|
||||
"value": "magic-swordman"
|
||||
},
|
||||
{
|
||||
"label": "Martial Arts",
|
||||
"value": "martial-arts"
|
||||
},
|
||||
{
|
||||
"label": "Mature",
|
||||
"value": "mature"
|
||||
},
|
||||
{
|
||||
"label": "Mecha",
|
||||
"value": "mecha"
|
||||
},
|
||||
{
|
||||
"label": "Modern",
|
||||
"value": "modern"
|
||||
},
|
||||
{
|
||||
"label": "Mystery",
|
||||
"value": "mystery"
|
||||
},
|
||||
{
|
||||
"label": "Omegaverse",
|
||||
"value": "omegaverse"
|
||||
},
|
||||
{
|
||||
"label": "Psychological",
|
||||
"value": "psychological"
|
||||
},
|
||||
{
|
||||
"label": "Rebirth",
|
||||
"value": "rebirth"
|
||||
},
|
||||
{
|
||||
"label": "Revenge",
|
||||
"value": "revenge"
|
||||
},
|
||||
{
|
||||
"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": "Shounen Ai",
|
||||
"value": "shounen-ai"
|
||||
},
|
||||
{
|
||||
"label": "Slice of Life",
|
||||
"value": "slice-of-life"
|
||||
},
|
||||
{
|
||||
"label": "Smut",
|
||||
"value": "smut"
|
||||
},
|
||||
{
|
||||
"label": "Supernatural",
|
||||
"value": "supernatural"
|
||||
},
|
||||
{
|
||||
"label": "Thriller",
|
||||
"value": "thriller"
|
||||
},
|
||||
{
|
||||
"label": "Tragedy",
|
||||
"value": "tragedy"
|
||||
},
|
||||
{
|
||||
"label": "Transmigration",
|
||||
"value": "transmigration"
|
||||
},
|
||||
{
|
||||
"label": "Villainess",
|
||||
"value": "villainess"
|
||||
},
|
||||
{
|
||||
"label": "Web Novel",
|
||||
"value": "web-novel"
|
||||
},
|
||||
{
|
||||
"label": "Western Style",
|
||||
"value": "western-style"
|
||||
},
|
||||
{
|
||||
"label": "Wuxia",
|
||||
"value": "wuxia"
|
||||
},
|
||||
{
|
||||
"label": "Xianxia",
|
||||
"value": "xianxia"
|
||||
},
|
||||
{
|
||||
"label": "Xuanhuan",
|
||||
"value": "xuanhuan"
|
||||
},
|
||||
{
|
||||
"label": "Yaoi",
|
||||
"value": "yaoi"
|
||||
},
|
||||
{
|
||||
"label": "Yuri",
|
||||
"value": "yuri"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Type",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Chinese",
|
||||
"value": "chinese"
|
||||
},
|
||||
{
|
||||
"label": "Drama",
|
||||
"value": "drama"
|
||||
},
|
||||
{
|
||||
"label": "Indonesian",
|
||||
"value": "indonesian"
|
||||
},
|
||||
{
|
||||
"label": "Japanese",
|
||||
"value": "japanese"
|
||||
},
|
||||
{
|
||||
"label": "Korean",
|
||||
"value": "korean"
|
||||
},
|
||||
{
|
||||
"label": "Original",
|
||||
"value": "original"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"type": "Picker",
|
||||
"label": "Status",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "All",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "Ongoing",
|
||||
"value": "ongoing"
|
||||
},
|
||||
{
|
||||
"label": "Hiatus",
|
||||
"value": "hiatus"
|
||||
},
|
||||
{
|
||||
"label": "Completed",
|
||||
"value": "completed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"order": {
|
||||
"type": "Picker",
|
||||
"label": "Order by",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "Default",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "A-Z",
|
||||
"value": "title"
|
||||
},
|
||||
{
|
||||
"label": "Z-A",
|
||||
"value": "titlereverse"
|
||||
},
|
||||
{
|
||||
"label": "Latest Update",
|
||||
"value": "update"
|
||||
},
|
||||
{
|
||||
"label": "Latest Added",
|
||||
"value": "latest"
|
||||
},
|
||||
{
|
||||
"label": "Popular",
|
||||
"value": "popular"
|
||||
},
|
||||
{
|
||||
"label": "Rating",
|
||||
"value": "rating"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user