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,310 @@
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
|
||||
const headers: any = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Inertia': true,
|
||||
'X-Inertia-Version': '6666cd76f96956469e7be39d750cc7d9',
|
||||
};
|
||||
|
||||
class freedlit implements Plugin.PluginBase {
|
||||
id = 'freedlit.space';
|
||||
name = 'LitSpace';
|
||||
site = 'https://freedlit.space';
|
||||
version = '1.1.0';
|
||||
icon = 'src/ru/freedlit/icon.png';
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site + '/get-books/?sort=';
|
||||
url += showLatestNovels ? 'recent' : filters?.sort?.value || 'popular';
|
||||
url += '&access=' + (filters?.access?.value || 'all');
|
||||
url += '&hideAdult=' + (filters?.hideAdult?.value || false);
|
||||
url += '&page=' + page;
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
const { books }: { books: Books } = await fetchApi(url).then(res => {
|
||||
this.getToken(res.headers);
|
||||
return res.json();
|
||||
});
|
||||
|
||||
books.data.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.title,
|
||||
cover: novel.cover
|
||||
? this.site + '/storage/' + novel.cover
|
||||
: defaultCover,
|
||||
path: novel.item_id.toString(),
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const {
|
||||
props: { book },
|
||||
}: { props: { book: DataEntity } } = await fetchApi(
|
||||
this.resolveUrl(novelPath, true),
|
||||
{
|
||||
headers,
|
||||
Referer: this.resolveUrl(novelPath, true),
|
||||
},
|
||||
).then(res => {
|
||||
this.getToken(res.headers);
|
||||
return res.json();
|
||||
});
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: book.title,
|
||||
cover: book.cover ? this.site + '/storage/' + book.cover : defaultCover,
|
||||
summary: book.annotation,
|
||||
author: book.authors_names?.[0]?.name || '',
|
||||
genres: book.tags?.map?.(tags => tags.name)?.join?.(', ') || '',
|
||||
};
|
||||
|
||||
const { success }: { success: { items: chaptersData[] } } = await fetchApi(
|
||||
this.site + '/api/bookpage/get-chapters',
|
||||
{
|
||||
method: 'post',
|
||||
headers,
|
||||
Referer: this.resolveUrl(novelPath),
|
||||
body: JSON.stringify({ book_id: novelPath }),
|
||||
},
|
||||
).then(res => {
|
||||
this.getToken(res.headers);
|
||||
return res.json();
|
||||
});
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
if (success?.items?.length) {
|
||||
success.items.forEach((chapter, chapterIndex) =>
|
||||
chapters.push({
|
||||
name: chapter.header,
|
||||
path: novelPath + '/' + chapter.id,
|
||||
releaseTime: chapter.first_published_formated,
|
||||
chapterNumber: chapterIndex + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const [book_id, chapter_id] = chapterPath.split('/');
|
||||
if (!headers['X-XSRF-TOKEN']) {
|
||||
await fetchApi(this.site).then(res => this.getToken(res.headers));
|
||||
}
|
||||
|
||||
const { success }: { success: chapterContent } = await fetchApi(
|
||||
this.site + '/reader/get-content',
|
||||
{
|
||||
method: 'post',
|
||||
headers,
|
||||
Referer: this.resolveUrl(chapterPath),
|
||||
body: JSON.stringify({ book_id, chapter_id }),
|
||||
},
|
||||
).then(res => {
|
||||
this.getToken(res.headers);
|
||||
return res.json();
|
||||
});
|
||||
|
||||
const chapterText = success.content;
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const { success }: { success: book[] } = await fetchApi(
|
||||
this.site + '/api/search?query=' + searchTerm,
|
||||
).then(res => {
|
||||
this.getToken(res.headers);
|
||||
return res.json();
|
||||
});
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
if (success?.length) {
|
||||
success.forEach(novel => {
|
||||
if (novel.type === 'book' && novel.title) {
|
||||
novels.push({
|
||||
name: novel.title,
|
||||
cover: novel.cover
|
||||
? this.site + '/storage/' + novel.cover
|
||||
: defaultCover,
|
||||
path: novel.id.toString(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
resolveUrl = (path: string, isNovel?: boolean) =>
|
||||
this.site + (isNovel ? '/book/' : '/reader/') + path;
|
||||
|
||||
getToken = (header: any) => {
|
||||
const cookies = header.map['set-cookie'] || '';
|
||||
for (const cookie of cookies.split('; ') as string[]) {
|
||||
const [key, val] = cookie.split('=');
|
||||
if (key === 'XSRF-TOKEN') {
|
||||
headers['X-XSRF-TOKEN'] = decodeURIComponent(val);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!headers['X-XSRF-TOKEN']) throw new Error('Failed to find the token');
|
||||
};
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Сортировка:',
|
||||
value: 'popular',
|
||||
options: [
|
||||
{ label: 'По популярности', value: 'popular' },
|
||||
{ label: 'последние обновления', value: 'updated' },
|
||||
{ label: 'По новизне', value: 'recent' },
|
||||
{ label: 'По просмотрам', value: 'views' },
|
||||
{ label: 'По количеству лайков', value: 'likes' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
access: {
|
||||
label: 'Доступ:',
|
||||
value: 'all',
|
||||
options: [
|
||||
{ label: 'Любой доступ', value: 'all' },
|
||||
{ label: 'Бесплатные', value: 'free' },
|
||||
{ label: 'Платные', value: 'paid' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
hideAdult: {
|
||||
label: 'Скрыть 18+',
|
||||
value: true,
|
||||
type: FilterTypes.Switch,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new freedlit();
|
||||
|
||||
type Books = {
|
||||
current_page: number;
|
||||
data: DataEntity[];
|
||||
first_page_url: string;
|
||||
from: number;
|
||||
last_page: number;
|
||||
last_page_url: string;
|
||||
next_page_url: string;
|
||||
path: string;
|
||||
per_page: number;
|
||||
prev_page_url?: null;
|
||||
to: number;
|
||||
total: number;
|
||||
};
|
||||
type DataEntity = {
|
||||
id: number;
|
||||
item_id: number;
|
||||
type: string;
|
||||
form_id: number;
|
||||
title: string;
|
||||
authors: string;
|
||||
cover: string;
|
||||
access: string;
|
||||
language: string;
|
||||
rating: number;
|
||||
intermediate_rating?: number;
|
||||
first_published: string;
|
||||
chapter_updated_at: string;
|
||||
adults: number;
|
||||
exclusive: number;
|
||||
status: number;
|
||||
total_views: number;
|
||||
total_likes: number;
|
||||
show_main_widget: number;
|
||||
is_audio: boolean;
|
||||
authors_names?: AuthorsNamesEntity[];
|
||||
client_price?: number | null;
|
||||
client_discount?: null;
|
||||
seriesBook: boolean;
|
||||
series_title: string;
|
||||
main_author_link: string;
|
||||
publisher_link?: null;
|
||||
published_chapters_total: number;
|
||||
total_characters: string;
|
||||
total_author_sheet: number;
|
||||
total_critics: number;
|
||||
total_recommendations: number;
|
||||
form_name: string;
|
||||
genres?: GenresEntity[] | null;
|
||||
tags?: TagsEntity[];
|
||||
series_book_num: number;
|
||||
series_book_id?: number | null;
|
||||
annotation: string;
|
||||
total_comments: number;
|
||||
total_libraries: number;
|
||||
};
|
||||
type AuthorsNamesEntity = {
|
||||
id: number;
|
||||
url: string;
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
type GenresEntity = {
|
||||
id: number;
|
||||
};
|
||||
type TagsEntity = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type chaptersData = {
|
||||
id: number;
|
||||
header: string;
|
||||
first_published_formated: string;
|
||||
intro_snippet_end: number;
|
||||
};
|
||||
|
||||
type chapterContent = {
|
||||
id: number;
|
||||
book_id: number;
|
||||
header: string;
|
||||
content: string;
|
||||
number: number;
|
||||
user_files?: null;
|
||||
intro_snippet_end: number;
|
||||
status: number;
|
||||
first_published: number;
|
||||
auto_publication?: null;
|
||||
first_published_at?: null;
|
||||
is_chapter_avalaible: boolean;
|
||||
total_characters: number;
|
||||
total_characters_clear: string;
|
||||
};
|
||||
|
||||
export type book = {
|
||||
id: number;
|
||||
cover?: string | null;
|
||||
title?: string | null;
|
||||
type: string;
|
||||
authors_names?: AuthorsNamesEntity[];
|
||||
form_name?: string | null;
|
||||
relevance: number;
|
||||
for_blog?: number | null;
|
||||
for_book?: number | null;
|
||||
items_count?: number | null;
|
||||
name?: string | null;
|
||||
blog_category_id?: number | null;
|
||||
author_name?: string | null;
|
||||
user_link?: string | null;
|
||||
img?: string | null;
|
||||
};
|
||||
@@ -0,0 +1,454 @@
|
||||
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 { Parser } from 'htmlparser2';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class AuthorToday implements Plugin.PluginBase {
|
||||
id = 'AT';
|
||||
name = 'Автор Тудей';
|
||||
icon = 'src/ru/authortoday/icon.png';
|
||||
site = 'https://author.today';
|
||||
version = '1.2.1';
|
||||
|
||||
private userAgent =
|
||||
'Mozilla/5.0 (Android 15; Mobile; rv:138.0) Gecko/138.0 Firefox/138.0';
|
||||
|
||||
parseNovels(url: string) {
|
||||
return fetchApi(url, { headers: { 'User-Agent': this.userAgent } })
|
||||
.then(res => res.text())
|
||||
.then(html => {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
let tempNovel = {} as Plugin.NovelItem;
|
||||
let isParsingNovel = false;
|
||||
let isParsingName = false;
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (
|
||||
name === 'a' &&
|
||||
attribs['class'] === 'work-row item-link item-content' &&
|
||||
attribs['href']
|
||||
) {
|
||||
tempNovel.path = attribs['href'].replace(/\D/g, '');
|
||||
isParsingNovel = true;
|
||||
}
|
||||
if (
|
||||
isParsingNovel &&
|
||||
name === 'h4' &&
|
||||
attribs['class'] === 'work-title'
|
||||
) {
|
||||
isParsingName = true;
|
||||
}
|
||||
if (
|
||||
isParsingNovel &&
|
||||
name === 'img' &&
|
||||
attribs['alt'] === 'Обложка'
|
||||
) {
|
||||
tempNovel.cover = attribs['data-src'];
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isParsingName) {
|
||||
tempNovel.name = data.trim();
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'h4') {
|
||||
isParsingName = false;
|
||||
}
|
||||
if (name === 'a' && isParsingNovel) {
|
||||
if (!tempNovel.cover) {
|
||||
tempNovel.cover = defaultCover;
|
||||
}
|
||||
novels.push(tempNovel);
|
||||
tempNovel = {} as Plugin.NovelItem;
|
||||
isParsingNovel = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
return novels;
|
||||
});
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ showLatestNovels, filters }: Plugin.PopularNovelsOptions,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site + '/work/genre/' + (filters?.genre?.value || 'all');
|
||||
|
||||
url +=
|
||||
'?sorting=' +
|
||||
(showLatestNovels ? 'recent' : filters?.sort?.value || 'popular');
|
||||
|
||||
if (filters?.form?.value) url += '&form=' + filters.form.value;
|
||||
if (filters?.state?.value) url += '&state=' + filters.state.value;
|
||||
if (filters?.series?.value) url += '&series=' + filters.series.value;
|
||||
if (filters?.access?.value) url += '&access=' + filters.access.value;
|
||||
if (filters?.promo?.value) url += '&promo=' + filters.promo.value;
|
||||
url += '&page=' + pageNo;
|
||||
|
||||
return this.parseNovels(url);
|
||||
}
|
||||
|
||||
async parseNovel(workID: string): Promise<Plugin.SourceNovel> {
|
||||
const html = await fetchApi(this.resolveUrl(workID, true), {
|
||||
headers: { 'User-Agent': this.userAgent },
|
||||
}).then(res => res.text());
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: workID,
|
||||
name: '',
|
||||
author: '',
|
||||
summary: '',
|
||||
};
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
let isParsingName = false;
|
||||
let isParsingAuthor = false;
|
||||
let isReadingSummary = false;
|
||||
let isParsingGenres = false;
|
||||
let isParsingStatus = false;
|
||||
let isParsingChapter = false;
|
||||
let isReadingChapterName = false;
|
||||
|
||||
let tempChapter = {} as Plugin.ChapterItem;
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (name === 'h1' && attribs['class'] === 'card-title') {
|
||||
isParsingName = true;
|
||||
}
|
||||
if (name === 'img' && attribs['class'] === 'cover-image') {
|
||||
novel.cover = attribs['src'];
|
||||
}
|
||||
if (
|
||||
name === 'a' &&
|
||||
attribs['href'] &&
|
||||
attribs['href'].endsWith('/works')
|
||||
) {
|
||||
isParsingAuthor = true;
|
||||
}
|
||||
if (name === 'div' && attribs['class'] === 'rich-content') {
|
||||
isReadingSummary = true;
|
||||
}
|
||||
if (name === 'br' && isReadingSummary) {
|
||||
novel.summary += '\n';
|
||||
}
|
||||
if (name === 'noindex') {
|
||||
isParsingGenres = true;
|
||||
if (novel.genres) novel.genres = '';
|
||||
}
|
||||
if (
|
||||
name === 'label' &&
|
||||
attribs['class'] &&
|
||||
attribs['class'].includes('label')
|
||||
) {
|
||||
isParsingStatus = true;
|
||||
}
|
||||
if (name === 'li' && attribs['class'] === 'clearfix') {
|
||||
isParsingChapter = true;
|
||||
}
|
||||
if (name === 'a' && isParsingChapter && attribs['href']) {
|
||||
isReadingChapterName = true;
|
||||
tempChapter.path = attribs.href.replace('/reader/', '');
|
||||
}
|
||||
if (name === 'span' && attribs['data-time']) {
|
||||
tempChapter.releaseTime = dayjs(attribs['data-time']).format('LLL');
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isParsingName) {
|
||||
novel.name = data.trim();
|
||||
isParsingName = false;
|
||||
}
|
||||
if (isParsingAuthor && data.trim()) {
|
||||
novel.author += data.trim() + ', ';
|
||||
}
|
||||
if (isReadingSummary) {
|
||||
novel.summary += data.trim();
|
||||
}
|
||||
if (isParsingGenres) {
|
||||
novel.genres += data;
|
||||
}
|
||||
if (isParsingStatus) {
|
||||
const status = data.trim();
|
||||
switch (status) {
|
||||
case 'в процессе':
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
break;
|
||||
case 'весь текст':
|
||||
novel.status = NovelStatus.Completed;
|
||||
break;
|
||||
default:
|
||||
novel.status = NovelStatus.Unknown;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isReadingChapterName) {
|
||||
tempChapter.name = data.trim();
|
||||
isReadingChapterName = false;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'div' && isParsingAuthor) {
|
||||
isParsingAuthor = false;
|
||||
novel.author = novel.author?.slice(0, -2);
|
||||
}
|
||||
if (name === 'div' && isReadingSummary) {
|
||||
isReadingSummary = false;
|
||||
}
|
||||
if (name === 'noindex') {
|
||||
isParsingGenres = false;
|
||||
}
|
||||
if (name === 'label') {
|
||||
isParsingStatus = false;
|
||||
}
|
||||
if (name === 'li' && isParsingChapter) {
|
||||
tempChapter.chapterNumber = chapters.length;
|
||||
if (tempChapter.path) {
|
||||
chapters?.push(tempChapter);
|
||||
}
|
||||
tempChapter = {} as Plugin.ChapterItem;
|
||||
isParsingChapter = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
|
||||
if (!novel.cover) {
|
||||
novel.cover = defaultCover;
|
||||
}
|
||||
if (!chapters.length) {
|
||||
chapters.push({
|
||||
name: 'Рассказ',
|
||||
path: workID,
|
||||
});
|
||||
}
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const html = await fetchApi(this.resolveUrl(chapterPath), {
|
||||
headers: { 'User-Agent': this.userAgent },
|
||||
}).then(res => res.text());
|
||||
|
||||
let [workID, chapterID] = chapterPath.split('/');
|
||||
const userRaw = html.match(/userId:(.*?),/)?.[1]?.trim();
|
||||
const userId = userRaw === 'null' ? '' : userRaw;
|
||||
|
||||
if (!chapterID) {
|
||||
chapterID = html.match(/chapterId:(.*?),/)?.[1]?.trim() || '';
|
||||
}
|
||||
if (!chapterID) throw new Error('Chapter ID not found');
|
||||
|
||||
const chapter = await fetchApi(
|
||||
this.site + '/reader/' + workID + '/chapter?id=' + chapterID,
|
||||
{
|
||||
headers: { 'User-Agent': this.userAgent },
|
||||
},
|
||||
);
|
||||
const chapterJson: Result = await chapter.json();
|
||||
const key = chapter.headers.get('reader-secret');
|
||||
|
||||
if (!key)
|
||||
throw new Error('Failed to find the token\n' + chapterJson.messages);
|
||||
|
||||
const chapterText = decrypt(chapterJson.data.text, key, userId);
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number | undefined = 1,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url =
|
||||
this.site + '/search?category=works&q=' + searchTerm + '&page=' + pageNo;
|
||||
|
||||
return this.parseNovels(url);
|
||||
}
|
||||
|
||||
resolveUrl = (path: string, isNovel?: boolean) =>
|
||||
this.site + (isNovel ? '/work/' : '/reader/') + path;
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Сортировка',
|
||||
value: 'popular',
|
||||
options: [
|
||||
{ label: 'По популярности', value: 'popular' },
|
||||
{ label: 'По количеству лайков', value: 'likes' },
|
||||
{ label: 'По комментариям', value: 'comments' },
|
||||
{ label: 'По новизне', value: 'recent' },
|
||||
{ label: 'По просмотрам', value: 'views' },
|
||||
{ label: 'Набирающие популярность', value: 'trending' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genre: {
|
||||
label: 'Жанры',
|
||||
value: '',
|
||||
options: [
|
||||
{ label: 'Все', value: '' },
|
||||
{ label: 'Альтернативная история', value: 'sf-history' },
|
||||
{ label: 'Антиутопия', value: 'dystopia' },
|
||||
{ label: 'Бизнес-литература', value: 'biznes-literatura' },
|
||||
{ label: 'Боевая фантастика', value: 'sf-action' },
|
||||
{ label: 'Боевик', value: 'action' },
|
||||
{ label: 'Боевое фэнтези', value: 'fantasy-action' },
|
||||
{ label: 'Бояръ-Аниме', value: 'boyar-anime' },
|
||||
{ label: 'Героическая фантастика', value: 'sf-heroic' },
|
||||
{ label: 'Героическое фэнтези', value: 'heroic-fantasy' },
|
||||
{ label: 'Городское фэнтези', value: 'urban-fantasy' },
|
||||
{ label: 'Детектив', value: 'detective' },
|
||||
{ label: 'Детская литература', value: 'detskaya-literatura' },
|
||||
{ label: 'Документальная проза', value: 'non-fiction' },
|
||||
{ label: 'Историческая проза', value: 'historical-fiction' },
|
||||
{ label: 'Исторические приключения', value: 'historical-adventure' },
|
||||
{ label: 'Исторический детектив', value: 'historical-mystery' },
|
||||
{ label: 'Исторический любовный роман', value: 'historical-romance' },
|
||||
{ label: 'Историческое фэнтези', value: 'historical-fantasy' },
|
||||
{ label: 'Киберпанк', value: 'cyberpunk' },
|
||||
{ label: 'Короткий любовный роман', value: 'short-romance' },
|
||||
{ label: 'Космическая фантастика', value: 'sf-space' },
|
||||
{ label: 'ЛитРПГ', value: 'litrpg' },
|
||||
{ label: 'Любовное фэнтези', value: 'love-fantasy' },
|
||||
{ label: 'Любовные романы', value: 'romance' },
|
||||
{ label: 'Мистика', value: 'paranormal' },
|
||||
{ label: 'Назад в СССР', value: 'back-to-ussr' },
|
||||
{ label: 'Научная фантастика', value: 'science-fiction' },
|
||||
{ label: 'Подростковая проза', value: 'teen-prose' },
|
||||
{ label: 'Политический роман', value: 'political-fiction' },
|
||||
{ label: 'Попаданцы', value: 'popadantsy' },
|
||||
{ label: 'Попаданцы в космос', value: 'popadantsy-v-kosmos' },
|
||||
{
|
||||
label: 'Попаданцы в магические миры',
|
||||
value: 'popadantsy-v-magicheskie-miry',
|
||||
},
|
||||
{ label: 'Попаданцы во времени', value: 'popadantsy-vo-vremeni' },
|
||||
{ label: 'Постапокалипсис', value: 'postapocalyptic' },
|
||||
{ label: 'Поэзия', value: 'poetry' },
|
||||
{ label: 'Приключения', value: 'adventure' },
|
||||
{ label: 'Публицистика', value: 'publicism' },
|
||||
{ label: 'Развитие личности', value: 'razvitie-lichnosti' },
|
||||
{ label: 'Разное', value: 'other' },
|
||||
{ label: 'РеалРПГ', value: 'realrpg' },
|
||||
{ label: 'Романтическая эротика', value: 'romantic-erotika' },
|
||||
{ label: 'Сказка', value: 'fairy-tale' },
|
||||
{ label: 'Современная проза', value: 'modern-prose' },
|
||||
{ label: 'Современный любовный роман', value: 'contemporary-romance' },
|
||||
{ label: 'Социальная фантастика', value: 'sf-social' },
|
||||
{ label: 'Стимпанк', value: 'steampunk' },
|
||||
{ label: 'Темное фэнтези', value: 'dark-fantasy' },
|
||||
{ label: 'Триллер', value: 'thriller' },
|
||||
{ label: 'Ужасы', value: 'horror' },
|
||||
{ label: 'Фантастика', value: 'sci-fi' },
|
||||
{
|
||||
label: 'Фантастический детектив',
|
||||
value: 'detective-science-fiction',
|
||||
},
|
||||
{ label: 'Фанфик', value: 'fanfiction' },
|
||||
{ label: 'Фэнтези', value: 'fantasy' },
|
||||
{ label: 'Шпионский детектив', value: 'spy-mystery' },
|
||||
{ label: 'Эпическое фэнтези', value: 'epic-fantasy' },
|
||||
{ label: 'Эротика', value: 'erotica' },
|
||||
{ label: 'Эротическая фантастика', value: 'sf-erotika' },
|
||||
{ label: 'Эротический фанфик', value: 'fanfiction-erotika' },
|
||||
{ label: 'Эротическое фэнтези', value: 'fantasy-erotika' },
|
||||
{ label: 'Юмор', value: 'humor' },
|
||||
{ label: 'Юмористическая фантастика', value: 'sf-humor' },
|
||||
{ label: 'Юмористическое фэнтези', value: 'ironical-fantasy' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
form: {
|
||||
label: 'Форма произведения',
|
||||
value: '',
|
||||
options: [
|
||||
{ label: 'Любой', value: '' },
|
||||
{ label: 'Перевод', value: 'translation' },
|
||||
{ label: 'Повесть', value: 'tale' },
|
||||
{ label: 'Рассказ', value: 'story' },
|
||||
{ label: 'Роман', value: 'novel' },
|
||||
{ label: 'Сборник поэзии', value: 'poetry' },
|
||||
{ label: 'Сборник рассказов', value: 'story-book' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
state: {
|
||||
label: 'Статус произведения',
|
||||
value: '',
|
||||
options: [
|
||||
{ label: 'Любой статус', value: '' },
|
||||
{ label: 'В процессе', value: 'in-progress' },
|
||||
{ label: 'Завершено', value: 'finished' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
series: {
|
||||
label: 'Статус цикла',
|
||||
value: '',
|
||||
options: [
|
||||
{ label: 'Не важно', value: '' },
|
||||
{ label: 'Вне цикла', value: 'out' },
|
||||
{ label: 'Цикл завершен', value: 'finished' },
|
||||
{ label: 'Цикл не завершен', value: 'unfinished' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
access: {
|
||||
label: 'Тип доступа',
|
||||
value: '',
|
||||
options: [
|
||||
{ label: 'Любой', value: '' },
|
||||
{ label: 'Платный', value: 'paid' },
|
||||
{ label: 'Бесплатный', value: 'free' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
promo: {
|
||||
label: 'Промо-фрагмент',
|
||||
value: 'hide',
|
||||
options: [
|
||||
{ label: 'Скрывать', value: 'hide' },
|
||||
{ label: 'Показывать', value: 'show' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new AuthorToday();
|
||||
|
||||
function decrypt(
|
||||
encrypt: string,
|
||||
encryptedKey: string,
|
||||
userId: string | number = '',
|
||||
) {
|
||||
const key = encryptedKey.split('').reverse().join('') + '@_@' + userId;
|
||||
const keyBytes = key.split('').map(char => char.charCodeAt(0));
|
||||
const keyLength = keyBytes.length;
|
||||
let text = '';
|
||||
|
||||
for (let i = 0; i < encrypt.length; i++) {
|
||||
text += String.fromCharCode(
|
||||
encrypt.charCodeAt(i) ^ keyBytes[i % keyLength],
|
||||
);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
type Result = {
|
||||
isSuccessful: boolean;
|
||||
isWarning: boolean;
|
||||
messages: string | null;
|
||||
data: Data;
|
||||
};
|
||||
|
||||
type Data = {
|
||||
text: string;
|
||||
};
|
||||
@@ -0,0 +1,299 @@
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const regex =
|
||||
/<script id="__NEXT_DATA__" type="application\/json">(\{.*?\})<\/script>/;
|
||||
|
||||
class Bookriver implements Plugin.PluginBase {
|
||||
id = 'bookriver';
|
||||
name = 'Bookriver';
|
||||
site = 'https://bookriver.ru';
|
||||
apiSite = 'https://api.bookriver.ru/api/v1/';
|
||||
version = '1.0.1';
|
||||
icon = 'src/ru/bookriver/icon.png';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site + '/genre?page=' + pageNo + '&perPage=24&sortingType=';
|
||||
url += showLatestNovels
|
||||
? 'last-update'
|
||||
: filters?.sort?.value || 'bestseller';
|
||||
|
||||
if (filters?.genres?.value?.length) {
|
||||
url += '&g=' + filters.genres.value.join(',');
|
||||
}
|
||||
|
||||
const result = await fetchApi(url).then(res => res.text());
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
const jsonRaw = result.match(regex);
|
||||
if (jsonRaw instanceof Array && jsonRaw[1]) {
|
||||
const json: response = JSON.parse(jsonRaw[1]);
|
||||
|
||||
json.props.pageProps.state.pagesFilter?.genre?.books?.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.name,
|
||||
cover: novel.coverImages[0].url,
|
||||
path: novel.slug,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const result = await fetchApi(this.resolveUrl(novelPath, true)).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: '',
|
||||
};
|
||||
|
||||
const jsonRaw = result.match(regex);
|
||||
if (jsonRaw instanceof Array && jsonRaw[1]) {
|
||||
const book: BooksEntity = JSON.parse(jsonRaw[1]).props.pageProps.state
|
||||
.book.bookPage;
|
||||
|
||||
novel.name = book.name || '';
|
||||
novel.cover = book.coverImages[0].url;
|
||||
novel.summary = book.annotation;
|
||||
novel.author = book.author?.name;
|
||||
|
||||
novel.status =
|
||||
book?.statusComplete === 'writing'
|
||||
? NovelStatus.Ongoing
|
||||
: NovelStatus.Completed;
|
||||
|
||||
if (book.tags?.length)
|
||||
novel.genres = book.tags?.map(tag => tag.name).join(',');
|
||||
|
||||
if (book?.ebook?.chapters?.length) {
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
book.ebook.chapters.forEach((chapter, chapterIndex) => {
|
||||
if (chapter.available) {
|
||||
chapters.push({
|
||||
name: chapter.name,
|
||||
path: novelPath + '/' + chapter.chapterId,
|
||||
releaseTime: dayjs(
|
||||
chapter.firstPublishedAt || chapter.createdAt || undefined,
|
||||
).format('LLL'),
|
||||
chapterNumber: chapterIndex + 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
novel.chapters = chapters;
|
||||
}
|
||||
}
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const url = this.apiSite + 'books/chapter/text/';
|
||||
const { data }: responseChapter = await fetchApi(
|
||||
url + chapterPath.split('/').pop(),
|
||||
).then(res => res.json());
|
||||
|
||||
let chapterText = data.content || 'Конец произведения';
|
||||
if (data?.audio?.available) {
|
||||
chapterText += '\n' + data.audio.url;
|
||||
}
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number | undefined = 1,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url =
|
||||
this.apiSite +
|
||||
'search/autocomplete?keyword=' +
|
||||
searchTerm +
|
||||
'&page=' +
|
||||
pageNo +
|
||||
'&perPage=10';
|
||||
const { data }: responseSearch = await fetchApi(url).then(res =>
|
||||
res.json(),
|
||||
);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
data?.books?.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.name,
|
||||
cover: novel.coverImages[0].url,
|
||||
path: novel.slug,
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
resolveUrl = (path: string, isNovel?: boolean) =>
|
||||
this.site + (isNovel ? '/book/' : '/reader/') + path;
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Сортировка',
|
||||
value: 'bestseller',
|
||||
options: [
|
||||
{ label: 'Бестселлеры', value: 'bestseller' },
|
||||
{ label: 'Дате добавления', value: 'newest' },
|
||||
{ label: 'Дате обновления', value: 'last-update' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genres: {
|
||||
label: 'жанры',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: 'Альтернативная история', value: 'alternativnaya-istoriya' },
|
||||
{ label: 'Боевая фантастика', value: 'boevaya-fantastika' },
|
||||
{ label: 'Боевое фэнтези', value: 'boevoe-fentezi' },
|
||||
{ label: 'Бытовое фэнтези', value: 'bytovoe-fentezi' },
|
||||
{ label: 'Героическая фантастика', value: 'geroicheskaya-fantastika' },
|
||||
{ label: 'Героическое фэнтези', value: 'geroicheskoe-fentezi' },
|
||||
{ label: 'Городское фэнтези', value: 'gorodskoe-fentezi' },
|
||||
{ label: 'Детектив', value: 'detektiv' },
|
||||
{ label: 'Детективная фантастика', value: 'detektivnaya-fantastika' },
|
||||
{ label: 'Жёсткая эротика', value: 'zhyostkaya-erotika' },
|
||||
{ label: 'Исторический детектив', value: 'istoricheskii-detektiv' },
|
||||
{
|
||||
label: 'Исторический любовный роман',
|
||||
value: 'istoricheskii-lyubovnyi-roman',
|
||||
},
|
||||
{ label: 'Историческое фэнтези', value: 'istoricheskoe-fentezi' },
|
||||
{ label: 'Киберпанк', value: 'kiberpank' },
|
||||
{ label: 'Классический детектив', value: 'klassicheskii-detektiv' },
|
||||
{ label: 'Короткий любовный роман', value: 'korotkii-lyubovnyi-roman' },
|
||||
{ label: 'Космическая фантастика', value: 'kosmicheskaya-fantastika' },
|
||||
{ label: 'Криминальный детектив', value: 'kriminalnyi-detektiv' },
|
||||
{ label: 'ЛитРПГ', value: 'litrpg' },
|
||||
{ label: 'Любовная фантастика', value: 'lyubovnaya-fantastika' },
|
||||
{ label: 'Любовное фэнтези', value: 'lyubovnoe-fentezi' },
|
||||
{ label: 'Любовный роман', value: 'lyubovnyi-roman' },
|
||||
{ label: 'Мистика', value: 'mistika' },
|
||||
{ label: 'Молодежная проза', value: 'molodezhnaya-proza' },
|
||||
{ label: 'Научная фантастика', value: 'nauchnaya-fantastika' },
|
||||
{
|
||||
label: 'Остросюжетный любовный роман',
|
||||
value: 'ostrosyuzhetnyi-lyubovnyi-roman',
|
||||
},
|
||||
{ label: 'Политический детектив', value: 'politicheskii-detektiv' },
|
||||
{ label: 'Попаданцы', value: 'popadantsy' },
|
||||
{ label: 'Постапокалипсис', value: 'postapokalipsis' },
|
||||
{
|
||||
label: 'Приключенческое фэнтези',
|
||||
value: 'priklyuchencheskoe-fentezi',
|
||||
},
|
||||
{ label: 'Романтическая эротика', value: 'romanticheskaya-erotika' },
|
||||
{ label: 'С элементами эротики', value: 's-elementami-erotiki' },
|
||||
{ label: 'Славянское фэнтези', value: 'slavyanskoe-fentezi' },
|
||||
{
|
||||
label: 'Современный любовный роман',
|
||||
value: 'sovremennyi-lyubovnyi-roman',
|
||||
},
|
||||
{ label: 'Социальная фантастика', value: 'sotsialnaya-fantastika' },
|
||||
{ label: 'Тёмное фэнтези', value: 'temnoe-fentezi' },
|
||||
{ label: 'Фантастика', value: 'fantastika' },
|
||||
{ label: 'Фэнтези', value: 'fentezi' },
|
||||
{ label: 'Шпионский детектив', value: 'shpionskii-detektiv' },
|
||||
{ label: 'Эпическое фэнтези', value: 'epicheskoe-fentezi' },
|
||||
{ label: 'Эротика', value: 'erotika' },
|
||||
{ label: 'Эротическая фантастика', value: 'eroticheskaya-fantastika' },
|
||||
{ label: 'Эротический фанфик', value: 'eroticheskii-fanfik' },
|
||||
{ label: 'Эротическое фэнтези', value: 'eroticheskoe-fentezi' },
|
||||
{
|
||||
label: 'Юмористический детектив',
|
||||
value: 'yumoristicheskii-detektiv',
|
||||
},
|
||||
{ label: 'Юмористическое фэнтези', value: 'yumoristicheskoe-fentezi' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
export default new Bookriver();
|
||||
|
||||
type response = {
|
||||
props: {
|
||||
pageProps: {
|
||||
state: {
|
||||
book?: {
|
||||
bookPage: BooksEntity;
|
||||
};
|
||||
pagesFilter?: {
|
||||
genre: {
|
||||
books: BooksEntity[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type BooksEntity = {
|
||||
name: string;
|
||||
coverImages: { url: string }[];
|
||||
slug: string;
|
||||
annotation?: string;
|
||||
author?: {
|
||||
name: string;
|
||||
};
|
||||
tags?: { name: string }[];
|
||||
ebook?: {
|
||||
chapters: Chapter[];
|
||||
};
|
||||
statusComplete: string;
|
||||
};
|
||||
|
||||
type Chapter = {
|
||||
name: string;
|
||||
available: boolean;
|
||||
firstPublishedAt?: Date | null;
|
||||
createdAt?: Date | null;
|
||||
chapterId: number | string;
|
||||
};
|
||||
|
||||
type responseChapter = {
|
||||
data: {
|
||||
id: number;
|
||||
bookId: number;
|
||||
name: string;
|
||||
content: string;
|
||||
symbols: number;
|
||||
number: number;
|
||||
authorPages: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
firstPublishedAt: string;
|
||||
status: string;
|
||||
protected: boolean;
|
||||
free: boolean;
|
||||
audio?: {
|
||||
available: boolean;
|
||||
url: string;
|
||||
duration: number;
|
||||
};
|
||||
publicationScheduledFor?: null;
|
||||
};
|
||||
};
|
||||
|
||||
type responseSearch = {
|
||||
data: Data;
|
||||
total: number;
|
||||
};
|
||||
type Data = {
|
||||
books?: BooksEntity[] | null;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,309 @@
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class Jaomix implements Plugin.PluginBase {
|
||||
id = 'jaomix.ru';
|
||||
name = 'Jaomix';
|
||||
site = 'https://jaomix.ru';
|
||||
version = '1.0.1';
|
||||
icon = 'src/ru/jaomix/icon.png';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site + '/?searchrn';
|
||||
|
||||
if (filters?.lang?.value?.length) {
|
||||
url += filters.lang.value
|
||||
.map((lang, idx) => `&lang[${idx}]=${lang}`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
if (filters?.genre?.value?.include?.length) {
|
||||
url += filters.genre.value.include
|
||||
.map((genre, idx) => `&genre[${idx}]=${genre}`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
if (filters?.genre?.value?.exclude?.length) {
|
||||
url += filters.genre.value.exclude
|
||||
.map((genre, idx) => `&delgenre[${idx}]=del ${genre}`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
url += '&sortcountchapt=' + (filters?.sortcountchapt?.value || '1');
|
||||
url += '&sortdaycreate=' + (filters?.sortdaycreate?.value || '1');
|
||||
url +=
|
||||
'&sortby=' +
|
||||
(showLatestNovels ? 'upd' : filters?.sortby?.value || 'topweek');
|
||||
url += '&gpage=' + pageNo;
|
||||
|
||||
const body = await fetchApi(url).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
loadedCheerio('div[class="block-home"] > div[class="one"]').each(
|
||||
(index, element) => {
|
||||
const name = loadedCheerio(element)
|
||||
.find('div[class="img-home"] > a')
|
||||
.attr('title');
|
||||
const cover = loadedCheerio(element)
|
||||
.find('div[class="img-home"] > a > img')
|
||||
.attr('src')
|
||||
?.replace('-150x150', '');
|
||||
const url = loadedCheerio(element)
|
||||
.find('div[class="img-home"] > a')
|
||||
.attr('href');
|
||||
|
||||
if (!name || !url) return;
|
||||
|
||||
novels.push({ name, cover, path: url.replace(this.site, '') });
|
||||
},
|
||||
);
|
||||
|
||||
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('div[class="desc-book"] > h1').text().trim(),
|
||||
cover: loadedCheerio('div[class="img-book"] > img').attr('src'),
|
||||
summary: loadedCheerio('div[id="desc-tab"]').text().trim(),
|
||||
};
|
||||
|
||||
loadedCheerio('#info-book > p').each(function () {
|
||||
const text = loadedCheerio(this).text().replace(/,/g, '').split(' ');
|
||||
if (text[0] === 'Автор:') {
|
||||
novel.author = text.splice(1).join(' ');
|
||||
} else if (text[0] === 'Жанры:') {
|
||||
novel.genres = text.splice(1).join(',');
|
||||
} else if (text[0] === 'Статус:') {
|
||||
novel.status = text.includes('продолжается')
|
||||
? NovelStatus.Ongoing
|
||||
: NovelStatus.Completed;
|
||||
}
|
||||
});
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
const totalChapters = loadedCheerio('.download-chapter div.title').length;
|
||||
|
||||
loadedCheerio('.download-chapter div.title').each(
|
||||
(chapterIndex, element) => {
|
||||
const name = loadedCheerio(element).find('a').attr('title');
|
||||
const url = loadedCheerio(element).find('a').attr('href');
|
||||
if (!name || !url) return;
|
||||
|
||||
const releaseDate = loadedCheerio(element).find('time').text();
|
||||
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(),
|
||||
);
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
loadedCheerio('div[class="adblock-service"]').remove();
|
||||
const chapterText =
|
||||
loadedCheerio('div[class="entry-content"]').html() || '';
|
||||
|
||||
return chapterText.replace(/<a[^>]*>(.*?)<\/a>/gi, '$1');
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number | undefined = 1,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url =
|
||||
this.site +
|
||||
'/?searchrn=' +
|
||||
searchTerm +
|
||||
'&but=Поиск по названию&sortby=upd&gpage=' +
|
||||
pageNo;
|
||||
const body = await fetchApi(url).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
loadedCheerio('div[class="block-home"] > div[class="one"]').each(
|
||||
(index, element) => {
|
||||
const name = loadedCheerio(element)
|
||||
.find('div[class="img-home"] > a')
|
||||
.attr('title');
|
||||
const cover = loadedCheerio(element)
|
||||
.find('div[class="img-home"] > a > img')
|
||||
.attr('src')
|
||||
?.replace('-150x150', '');
|
||||
const url = loadedCheerio(element)
|
||||
.find('div[class="img-home"] > a')
|
||||
.attr('href');
|
||||
|
||||
if (!name || !url) return;
|
||||
|
||||
novels.push({ name, cover, path: url.replace(this.site, '') });
|
||||
},
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
parseDate = (dateString: string | undefined = '') => {
|
||||
const months: Record<string, number> = {
|
||||
Янв: 1,
|
||||
Фев: 2,
|
||||
Мар: 3,
|
||||
Апр: 4,
|
||||
Май: 5,
|
||||
Июн: 6,
|
||||
Июл: 7,
|
||||
Авг: 8,
|
||||
Сен: 9,
|
||||
Окт: 10,
|
||||
Ноя: 11,
|
||||
Дек: 12,
|
||||
};
|
||||
|
||||
const [time, day, month, year] = dateString.split(' ');
|
||||
if (time && day && months[month] && year) {
|
||||
return dayjs(year + '-' + months[month] + '-' + day + ' ' + time).format(
|
||||
'LLL',
|
||||
);
|
||||
}
|
||||
|
||||
return dateString || null;
|
||||
};
|
||||
|
||||
filters = {
|
||||
sortby: {
|
||||
label: 'Сортировка:',
|
||||
value: 'topweek',
|
||||
options: [
|
||||
{ label: 'Топ недели', value: 'topweek' },
|
||||
{ label: 'По алфавиту', value: 'alphabet' },
|
||||
{ label: 'По дате обновления', value: 'upd' },
|
||||
{ label: 'По дате создания', value: 'new' },
|
||||
{ label: 'По просмотрам', value: 'count' },
|
||||
{ label: 'Топ года', value: 'topyear' },
|
||||
{ label: 'Топ дня', value: 'topday' },
|
||||
{ label: 'Топ за все время', value: 'alltime' },
|
||||
{ label: 'Топ месяца', value: 'topmonth' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
sortdaycreate: {
|
||||
label: 'Дата добавления:',
|
||||
value: '1',
|
||||
options: [
|
||||
{ label: 'Любое', value: '1' },
|
||||
{ label: 'От 120 до 180 дней', value: '1218' },
|
||||
{ label: 'От 180 до 365 дней', value: '1836' },
|
||||
{ label: 'От 30 до 60 дней', value: '3060' },
|
||||
{ label: 'От 365 дней', value: '365' },
|
||||
{ label: 'От 60 до 90 дней', value: '6090' },
|
||||
{ label: 'От 90 до 120 дней', value: '9012' },
|
||||
{ label: 'Послед. 30 дней', value: '30' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
sortcountchapt: {
|
||||
label: 'Количество глав:',
|
||||
value: '1',
|
||||
options: [
|
||||
{ label: 'Любое кол-во глав', value: '1' },
|
||||
{ label: 'До 500', value: '500' },
|
||||
{ label: 'От 1000 до 2000', value: '1020' },
|
||||
{ label: 'От 2000 до 3000', value: '2030' },
|
||||
{ label: 'От 3000 до 4000', value: '3040' },
|
||||
{ label: 'От 4000', value: '400' },
|
||||
{ label: 'От 500 до 1000', value: '510' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genre: {
|
||||
label: 'Жанры:',
|
||||
value: { include: [], exclude: [] },
|
||||
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: 'Adult', value: 'Adult' },
|
||||
{ label: 'Ecchi', value: 'Ecchi' },
|
||||
{ label: 'Josei', value: 'Josei' },
|
||||
{ label: 'Lolicon', value: 'Lolicon' },
|
||||
{ label: 'Mature', value: 'Mature' },
|
||||
{ label: 'Shoujo', value: 'Shoujo' },
|
||||
{ label: 'Wuxia', value: 'Wuxia' },
|
||||
{ label: 'Xianxia', value: 'Xianxia' },
|
||||
{ label: 'Xuanhuan', value: 'Xuanhuan' },
|
||||
{ label: 'Yaoi', value: 'Yaoi' },
|
||||
],
|
||||
type: FilterTypes.ExcludableCheckboxGroup,
|
||||
},
|
||||
lang: {
|
||||
label: 'Выбрать языки:',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: 'Английский', value: 'Английский' },
|
||||
{ label: 'Китайский', value: 'Китайский' },
|
||||
{ label: 'Корейский', value: 'Корейский' },
|
||||
{ label: 'Японский', value: 'Японский' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new Jaomix();
|
||||
@@ -0,0 +1,456 @@
|
||||
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';
|
||||
|
||||
const statusKey: Record<string, string> = {
|
||||
'0': NovelStatus.Unknown,
|
||||
'1': NovelStatus.Ongoing,
|
||||
'2': NovelStatus.Completed,
|
||||
'3': NovelStatus.OnHiatus,
|
||||
'4': NovelStatus.Cancelled,
|
||||
};
|
||||
|
||||
class Neobook implements Plugin.PluginBase {
|
||||
id = 'neobook';
|
||||
name = 'Neobook';
|
||||
site = 'https://neobook.org';
|
||||
apiSite = 'https://api.neobook.org/';
|
||||
version = '1.0.2';
|
||||
icon = 'src/ru/neobook/icon.png';
|
||||
|
||||
async fetchNovels(
|
||||
page: number,
|
||||
{
|
||||
filters,
|
||||
showLatestNovels,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
searchTerm?: string,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const formData = new FormData();
|
||||
formData.append('version', '4.4');
|
||||
formData.append('uid', '0');
|
||||
formData.append('utoken', '');
|
||||
formData.append('resource', 'general');
|
||||
formData.append('action', 'get_bundle');
|
||||
formData.append('bundle', 'bundle_books');
|
||||
formData.append('target', 'feed');
|
||||
formData.append('page', page.toString());
|
||||
formData.append('filter_category_id', filters?.category?.value || '0');
|
||||
formData.append('filter_completed', '-1');
|
||||
formData.append('filter_search', searchTerm || '');
|
||||
formData.append('filter_tags', filters?.tags?.value || '');
|
||||
formData.append(
|
||||
'filter_sort',
|
||||
showLatestNovels ? 'new' : filters?.sort?.value || 'popular',
|
||||
);
|
||||
formData.append('filter_timeread', filters?.timeread?.value || '0-999999');
|
||||
|
||||
const { bundle_books }: { bundle_books: BundleBooks } = await fetchApi(
|
||||
this.apiSite,
|
||||
{
|
||||
method: 'post',
|
||||
body: formData,
|
||||
referrer: this.site,
|
||||
},
|
||||
).then(res => res.json());
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
if (bundle_books.feed?.length) {
|
||||
bundle_books.feed?.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.title,
|
||||
cover: novel?.attachment?.image?.m || defaultCover,
|
||||
path: novel.token + '/',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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 novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: '',
|
||||
cover: defaultCover,
|
||||
};
|
||||
|
||||
const bookRaw = body.match(/var postData = ({.*?});/);
|
||||
if (bookRaw instanceof Array && bookRaw[1]) {
|
||||
const book: Novels = JSON.parse(bookRaw[1]);
|
||||
|
||||
novel.name = book.title;
|
||||
novel.summary = book.text?.replace?.(/<br>/g, '\n') || book.text_fix;
|
||||
novel.author =
|
||||
book.user && book.user.firstname && book.user.lastname
|
||||
? book.user.firstname + ' ' + book.user.lastname
|
||||
: book.user?.initials || '';
|
||||
novel.status = statusKey[book.status || '0'] || NovelStatus.Unknown;
|
||||
|
||||
if (book.attachment?.image?.m) novel.cover = book.attachment.image.m;
|
||||
if (book.tags?.length) novel.genres = book.tags.join(',');
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
book.chapters?.forEach((chapter, chapterIndex) => {
|
||||
if (chapter.access == '1' && chapter.status == '1') {
|
||||
chapters.push({
|
||||
name: chapter.title || 'Глава ' + (chapterIndex + 1),
|
||||
path: `?book=${book.token}&chapter=${chapter.token}`,
|
||||
releaseTime: null,
|
||||
chapterNumber: Number(chapter.sort) || chapterIndex + 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
novel.chapters = chapters;
|
||||
}
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchApi(this.resolveUrl(chapterPath)).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
|
||||
const bookRaw = body.match(/var data = ({.*?});/);
|
||||
let chapterText = '';
|
||||
|
||||
if (bookRaw instanceof Array && bookRaw[1]) {
|
||||
const token = chapterPath.split('=')[2];
|
||||
const book: Novels = JSON.parse(bookRaw[1]);
|
||||
const chapter = book.chapters?.find?.(chapter => chapter.token == token);
|
||||
chapterText = (chapter?.data?.html || '').replace(/<br>/g, '');
|
||||
}
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
resolveUrl = (path: string, isNovel?: boolean) =>
|
||||
this.site + (isNovel ? '/book/' : '/reader/') + path;
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Сортировка:',
|
||||
value: 'popular',
|
||||
options: [
|
||||
{ label: 'Сначала популярные', value: 'popular' },
|
||||
{ label: 'Сначала новые', value: 'new' },
|
||||
{ label: 'В случайном порядке', value: 'rand' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
timeread: {
|
||||
label: 'Время прочтения:',
|
||||
value: '0-999999',
|
||||
options: [
|
||||
{ label: 'Все', value: '0-999999' },
|
||||
{ label: 'Менее 15 минут', value: '0-900' },
|
||||
{ label: '15-30 минут', value: '900-1800' },
|
||||
{ label: '30-60 минут', value: '1800-3600' },
|
||||
{ label: '1-2 часа', value: '3600-7200' },
|
||||
{ label: 'Более 2 часов', value: '7200-999999' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
category: {
|
||||
label: 'Жанр:',
|
||||
value: '',
|
||||
options: [
|
||||
{ label: 'Все', value: '' },
|
||||
{ label: 'Антиутопия', value: '10' },
|
||||
{ label: 'Детектив', value: '13' },
|
||||
{ label: 'Детские книги', value: '14' },
|
||||
{ label: 'Драма', value: '15' },
|
||||
{ label: 'Другое', value: '16' },
|
||||
{ label: 'История', value: '18' },
|
||||
{ label: 'Мелодрама', value: '21' },
|
||||
{ label: 'Мистика', value: '22' },
|
||||
{ label: 'Научная фантастика', value: '23' },
|
||||
{ label: 'Нон-фикшн', value: '35' },
|
||||
{ label: 'Подростки и молодежь', value: '26' },
|
||||
{ label: 'Постапокалипсис', value: '27' },
|
||||
{ label: 'Поэзия', value: '28' },
|
||||
{ label: 'Приключения', value: '29' },
|
||||
{ label: 'Рассказ', value: '34' },
|
||||
{ label: 'Роман', value: '36' },
|
||||
{ label: 'Творчество', value: '40' },
|
||||
{ label: 'Триллер', value: '91' },
|
||||
{ label: 'Ужасы', value: '90' },
|
||||
{ label: 'Фантастика', value: '41' },
|
||||
{ label: 'Фанфик', value: '42' },
|
||||
{ label: 'Фэнтези', value: '44' },
|
||||
{ label: 'Эротика', value: '46' },
|
||||
{ label: 'Юмор', value: '47' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
tags: {
|
||||
label: 'Тэги:',
|
||||
value: '',
|
||||
type: FilterTypes.TextInput,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new Neobook();
|
||||
|
||||
type BundleBooks = {
|
||||
categories: any[];
|
||||
topSection: any[];
|
||||
feed: Novels[];
|
||||
};
|
||||
|
||||
type BottomSection = {
|
||||
id: string;
|
||||
boost: string;
|
||||
preferenceid: string;
|
||||
type_id: string;
|
||||
contentTypeid: string;
|
||||
name: string;
|
||||
title: string;
|
||||
more: string;
|
||||
part: string;
|
||||
items: Novels[];
|
||||
};
|
||||
|
||||
type Novels = {
|
||||
status?: string;
|
||||
error?: string;
|
||||
login?: Login;
|
||||
requestid?: string;
|
||||
bundleBooks?: BundleBooks;
|
||||
available?: string;
|
||||
boosted?: string;
|
||||
id?: string;
|
||||
token: string;
|
||||
type?: string;
|
||||
typeid?: string;
|
||||
verified?: string;
|
||||
audio?: string;
|
||||
labelid?: string;
|
||||
labelTitle?: string;
|
||||
languageid?: string;
|
||||
languageTitle?: string;
|
||||
categoryid?: string;
|
||||
categoryTitle?: string;
|
||||
price?: Price;
|
||||
priceSpecial?: Price;
|
||||
priceDiscount?: string;
|
||||
title: string;
|
||||
text: string;
|
||||
text_fix: string;
|
||||
copyright?: string;
|
||||
align?: string;
|
||||
completed?: string;
|
||||
timereadS?: string;
|
||||
views?: string;
|
||||
likes?: string;
|
||||
comments?: string;
|
||||
bookmarks?: string;
|
||||
isLike?: string;
|
||||
isBookmark?: string;
|
||||
isPurchase?: string;
|
||||
isSelf?: string;
|
||||
dateEdit?: string;
|
||||
datePublish?: string;
|
||||
link_path?: string;
|
||||
user?: NovelsUser;
|
||||
attachment?: Attachment;
|
||||
adult?: string;
|
||||
adultPlaceholder?: string;
|
||||
nft?: string;
|
||||
feed?: Feed;
|
||||
counters?: NovelsCounters;
|
||||
rating?: Rating;
|
||||
readTime?: ReadTime;
|
||||
readProgress?: ReadProgress;
|
||||
tags?: string[];
|
||||
chapters?: Chapter[];
|
||||
lastComments?: any[];
|
||||
bottomSection?: BottomSection;
|
||||
carouselItems?: Novels[];
|
||||
book?: Novels;
|
||||
activeChapterIndex?: number;
|
||||
};
|
||||
|
||||
type Attachment = {
|
||||
has: string;
|
||||
id: string;
|
||||
typeid: string;
|
||||
userid: string;
|
||||
width: string;
|
||||
height: string;
|
||||
duration: string;
|
||||
image: Image;
|
||||
video: string;
|
||||
origin: string;
|
||||
ext: string;
|
||||
};
|
||||
|
||||
type Image = {
|
||||
s: string;
|
||||
m: string;
|
||||
l: string;
|
||||
has?: string;
|
||||
};
|
||||
|
||||
type Chapter = {
|
||||
id: string;
|
||||
token: string;
|
||||
status: string;
|
||||
sort: string;
|
||||
free: string;
|
||||
title: string;
|
||||
words: string;
|
||||
access: string;
|
||||
loaded: string;
|
||||
data?: Data;
|
||||
};
|
||||
|
||||
type Data = {
|
||||
available: string;
|
||||
access: string;
|
||||
id: string;
|
||||
token: string;
|
||||
postid: string;
|
||||
free: string;
|
||||
title: string;
|
||||
html: string;
|
||||
dateEdit: string;
|
||||
datePublish: string;
|
||||
attachment: Attachment;
|
||||
lastComments: any[];
|
||||
};
|
||||
|
||||
type NovelsCounters = {
|
||||
impressions: Bookmarks;
|
||||
views: Bookmarks;
|
||||
likes: Bookmarks;
|
||||
comments: Bookmarks;
|
||||
bookmarks: Bookmarks;
|
||||
};
|
||||
|
||||
type Bookmarks = {
|
||||
value: string;
|
||||
formatted: string;
|
||||
};
|
||||
|
||||
type Feed = {
|
||||
name: string;
|
||||
canRemoved: string;
|
||||
};
|
||||
|
||||
type Login = {
|
||||
uid: string;
|
||||
utoken: string;
|
||||
has: string;
|
||||
banned: string;
|
||||
verified: string;
|
||||
acc: string;
|
||||
arm: string;
|
||||
grm: string;
|
||||
counters: LoginCounters;
|
||||
user: LoginUser;
|
||||
pro: any[];
|
||||
earn: any[];
|
||||
boost: any[];
|
||||
deposit: any[];
|
||||
affiliate: any[];
|
||||
preferences: any[];
|
||||
};
|
||||
|
||||
type LoginCounters = {
|
||||
notifications: string;
|
||||
messages: string;
|
||||
};
|
||||
|
||||
type LoginUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
photo: string;
|
||||
status: string;
|
||||
balance: Balance;
|
||||
connectedService: string;
|
||||
};
|
||||
|
||||
type Balance = {
|
||||
neo: Price;
|
||||
};
|
||||
|
||||
type Price = {
|
||||
has: string;
|
||||
value: string;
|
||||
currency: Currency;
|
||||
formatted: string;
|
||||
deadline: string;
|
||||
};
|
||||
|
||||
type Currency = 'NEO';
|
||||
|
||||
type Rating = {
|
||||
value: string;
|
||||
count: string;
|
||||
my: string;
|
||||
};
|
||||
|
||||
type ReadProgress = {
|
||||
chapter: string;
|
||||
progress: string;
|
||||
};
|
||||
|
||||
type ReadTime = {
|
||||
hours: string;
|
||||
minutes: string;
|
||||
};
|
||||
|
||||
type NovelsUser = {
|
||||
available: string;
|
||||
boosted: string;
|
||||
id: string;
|
||||
status: string;
|
||||
verified: string;
|
||||
metrica: string;
|
||||
cover: Image;
|
||||
photo: Image;
|
||||
username: string;
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
initials: string;
|
||||
link: string;
|
||||
about: string;
|
||||
subscriptions: string;
|
||||
subscribers: string;
|
||||
publications: string;
|
||||
books: string;
|
||||
poems: string;
|
||||
posts: string;
|
||||
drafts: string;
|
||||
isPro: string;
|
||||
isAdult: string;
|
||||
isBanned: string;
|
||||
isSubscription: string;
|
||||
isBlock: string;
|
||||
gr: string;
|
||||
};
|
||||
@@ -0,0 +1,481 @@
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class novelOvh implements Plugin.PluginBase {
|
||||
id = 'novelovh';
|
||||
name = 'НовелОВХ';
|
||||
site = 'https://novel.ovh';
|
||||
apiSite = 'https://api.novel.ovh/v2/';
|
||||
version = '1.0.3';
|
||||
icon = 'src/ru/novelovh/icon.png';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ showLatestNovels, filters }: Plugin.PopularNovelsOptions,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.apiSite + 'books?page=' + (pageNo - 1);
|
||||
url +=
|
||||
'&sort=' +
|
||||
(showLatestNovels
|
||||
? 'updatedAt'
|
||||
: filters?.sort?.value || 'averageRating') +
|
||||
',desc';
|
||||
|
||||
const books: BooksEntity[] = await fetchApi(url).then(res => res.json());
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
books.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.name.ru,
|
||||
cover: novel.poster,
|
||||
path: novel.slug,
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const { book, branches, chapters }: responseNovel = await fetchApi(
|
||||
this.site +
|
||||
'/content/' +
|
||||
novelPath +
|
||||
'?_data=routes/reader/book/$slug/index',
|
||||
).then(res => res.json());
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: book.name.ru,
|
||||
cover: book.poster,
|
||||
genres: book.labels?.map?.(label => label.name).join(','),
|
||||
summary: book.description,
|
||||
status:
|
||||
book.status == 'ONGOING' ? NovelStatus.Ongoing : NovelStatus.Completed,
|
||||
};
|
||||
|
||||
book.relations?.forEach(person => {
|
||||
switch (person.type) {
|
||||
case 'AUTHOR':
|
||||
novel.author = person.publisher.name;
|
||||
break;
|
||||
case 'ARTIST':
|
||||
novel.artist = person.publisher.name;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const branch_name: Record<string, string> = {};
|
||||
if (branches.length) {
|
||||
branches.forEach(({ id, publishers }) => {
|
||||
if (id && publishers?.length)
|
||||
branch_name[id] = publishers[0].name || 'Неизвестный';
|
||||
});
|
||||
}
|
||||
|
||||
const chaptersRes: Plugin.ChapterItem[] = [];
|
||||
chapters.forEach((chapter, chapterIndex) =>
|
||||
chaptersRes.push({
|
||||
name:
|
||||
chapter.title ||
|
||||
'Том ' +
|
||||
(chapter.volume || 0) +
|
||||
' ' +
|
||||
(chapter.name ||
|
||||
'Глава ' + (chapter.number || chapters.length - chapterIndex)),
|
||||
path: novelPath + '/' + chapter.id,
|
||||
releaseTime: dayjs(chapter.createdAt).format('LLL'),
|
||||
chapterNumber: chapters.length - chapterIndex,
|
||||
page: branch_name[chapter.branchId] || 'Главная страница',
|
||||
}),
|
||||
);
|
||||
|
||||
novel.chapters = chaptersRes.reverse();
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const book: responseChapter = await fetchApi(
|
||||
this.apiSite + 'chapters/' + chapterPath.split('/')[1],
|
||||
).then(res => res.json());
|
||||
|
||||
const image = Object.fromEntries(
|
||||
book?.pages?.map(({ id, image }) => [id, image]) || [],
|
||||
);
|
||||
|
||||
const chapterText = this.jsonToHtml(book.content.content || [], image);
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const url = this.apiSite + 'books?type=NOVEL&search=' + searchTerm;
|
||||
const books: BooksEntity[] = await fetchApi(url).then(res => res.json());
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
books.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.name.ru,
|
||||
cover: novel.poster,
|
||||
path: novel.slug,
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
jsonToHtml = (
|
||||
json: ContentEntity[],
|
||||
image: Record<string, string>,
|
||||
html = '',
|
||||
) => {
|
||||
json.forEach(element => {
|
||||
switch (element.type) {
|
||||
case 'image':
|
||||
if (element.attrs?.pages?.[0]) {
|
||||
html += `<img src="${image[element.attrs.pages[0]]}"/>`;
|
||||
}
|
||||
break;
|
||||
case 'hardBreak':
|
||||
html += '<br>';
|
||||
break;
|
||||
case 'horizontalRule':
|
||||
case 'delimiter':
|
||||
html += '<h2 style="text-align: center">***</h2>';
|
||||
break;
|
||||
case 'paragraph':
|
||||
html +=
|
||||
'<p>' +
|
||||
(element.content
|
||||
? this.jsonToHtml(element.content, image)
|
||||
: '<br>') +
|
||||
'</p>';
|
||||
break;
|
||||
case 'orderedList':
|
||||
html +=
|
||||
'<ol>' +
|
||||
(element.content
|
||||
? this.jsonToHtml(element.content, image)
|
||||
: '<br>') +
|
||||
'</ol>';
|
||||
break;
|
||||
case 'listItem':
|
||||
html +=
|
||||
'<li>' +
|
||||
(element.content
|
||||
? this.jsonToHtml(element.content, image)
|
||||
: '<br>') +
|
||||
'</li>';
|
||||
break;
|
||||
case 'blockquote':
|
||||
html +=
|
||||
'<blockquote>' +
|
||||
(element.content
|
||||
? this.jsonToHtml(element.content, image)
|
||||
: '<br>') +
|
||||
'</blockquote>';
|
||||
break;
|
||||
case 'italic':
|
||||
html +=
|
||||
'<i>' +
|
||||
(element.content
|
||||
? this.jsonToHtml(element.content, image)
|
||||
: '<br>') +
|
||||
'</i>';
|
||||
break;
|
||||
case 'bold':
|
||||
html +=
|
||||
'<b>' +
|
||||
(element.content
|
||||
? this.jsonToHtml(element.content, image)
|
||||
: '<br>') +
|
||||
'</b>';
|
||||
break;
|
||||
case 'underline':
|
||||
html +=
|
||||
'<u>' +
|
||||
(element.content
|
||||
? this.jsonToHtml(element.content, image)
|
||||
: '<br>') +
|
||||
'</u>';
|
||||
break;
|
||||
case 'heading':
|
||||
html +=
|
||||
'<h2>' +
|
||||
(element.content
|
||||
? this.jsonToHtml(element.content, image)
|
||||
: '<br>') +
|
||||
'</h2>';
|
||||
break;
|
||||
case 'text':
|
||||
html += element.text;
|
||||
break;
|
||||
default:
|
||||
html += JSON.stringify(element, null, '\t'); //maybe I missed something.
|
||||
break;
|
||||
}
|
||||
});
|
||||
return html;
|
||||
};
|
||||
|
||||
resolveUrl = (path: string) => this.site + '/content/' + path;
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Сортировка',
|
||||
value: 'averageRating',
|
||||
options: [
|
||||
{ label: 'Кол-во просмотров', value: 'viewsCount' },
|
||||
{ label: 'Кол-во лайков', value: 'likesCount' },
|
||||
{ label: 'Кол-во глав', value: 'chaptersCount' },
|
||||
{ label: 'Кол-во закладок', value: 'bookmarksCount' },
|
||||
{ label: 'Рейтингу', value: 'averageRating' },
|
||||
{ label: 'Дате создания', value: 'createdAt' },
|
||||
{ label: 'Дате обновления', value: 'updatedAt' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new novelOvh();
|
||||
|
||||
type BooksEntity = {
|
||||
id: string;
|
||||
slug: string;
|
||||
type: string;
|
||||
serviceName: string;
|
||||
poster: string;
|
||||
background?: string | null;
|
||||
featuredCharacter?: null;
|
||||
featuredCharacterBackground?: null;
|
||||
featuredCharacterAnimation?: null;
|
||||
featuredCharacterAnimationWithMask?: null;
|
||||
featuredCharacterAnimationFirstFrame?: null;
|
||||
status: string;
|
||||
contentStatus: string;
|
||||
name: Name;
|
||||
altNames?: AltNamesEntity[] | null;
|
||||
country: string;
|
||||
year: number;
|
||||
formats?: null[] | null;
|
||||
featured: boolean;
|
||||
viewsCount: number;
|
||||
likesCount: number;
|
||||
bookmarksCount: number;
|
||||
ratingVotesCount: number;
|
||||
averageRating: number;
|
||||
chaptersCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
type Name = {
|
||||
ru: string;
|
||||
original: string;
|
||||
en: string;
|
||||
};
|
||||
type AltNamesEntity = {
|
||||
language: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type responseNovel = {
|
||||
book: Book;
|
||||
branches: BranchesEntity[];
|
||||
chapters: ChaptersEntity[];
|
||||
related?: RelatedEntityOrBook[] | null;
|
||||
associated?: AssociatedEntity[] | null;
|
||||
chapterLikes?: null[] | null;
|
||||
covers?: CoversEntity[] | null;
|
||||
promotionVideos?: null[] | null;
|
||||
folderItems?: null[] | null;
|
||||
contentNotices?: null[] | null;
|
||||
};
|
||||
type Book = {
|
||||
id: string;
|
||||
slug: string;
|
||||
type: string;
|
||||
serviceName: string;
|
||||
poster: string;
|
||||
background?: null;
|
||||
featuredCharacter?: null;
|
||||
featuredCharacterBackground?: null;
|
||||
featuredCharacterAnimation?: null;
|
||||
featuredCharacterAnimationWithMask?: null;
|
||||
featuredCharacterAnimationFirstFrame?: null;
|
||||
contentBlockedCountries?: null[] | null;
|
||||
status: string;
|
||||
contentStatus: string;
|
||||
moderationStatus: string;
|
||||
name: Name;
|
||||
altNames?: null[] | null;
|
||||
externalLinks?: string[] | null;
|
||||
description: string;
|
||||
country: string;
|
||||
year: number;
|
||||
formats?: null[] | null;
|
||||
featured: boolean;
|
||||
viewsCount: number;
|
||||
viewsDayCount: number;
|
||||
viewsWeekCount: number;
|
||||
viewsMonthCount: number;
|
||||
likesCount: number;
|
||||
bookmarksCount: number;
|
||||
ratingVotesCount: number;
|
||||
averageRating: number;
|
||||
chaptersCount: number;
|
||||
labels?: LabelsEntity[] | null;
|
||||
relations?: RelationsEntity[] | null;
|
||||
createdById: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
rating?: null;
|
||||
};
|
||||
type LabelsEntity = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
};
|
||||
type RelationsEntity = {
|
||||
type: string;
|
||||
publisher: Publisher;
|
||||
};
|
||||
type Publisher = {
|
||||
id: string;
|
||||
kind: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
altNames?: (AltNamesEntity | null)[] | null;
|
||||
poster: string;
|
||||
background?: null;
|
||||
trusted: boolean;
|
||||
donationsEnabled: boolean;
|
||||
vkGroupId?: null;
|
||||
vkGroupName?: null;
|
||||
tonAddress?: null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
type BranchesEntity = {
|
||||
id: string;
|
||||
licensed: boolean;
|
||||
editorsChoice: boolean;
|
||||
chaptersCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
publishers?: PublishersEntity[] | null;
|
||||
subscription?: null;
|
||||
bookmark?: null;
|
||||
};
|
||||
type PublishersEntity = {
|
||||
id: string;
|
||||
kind: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
altNames?: AltNamesEntity[] | null;
|
||||
poster: string;
|
||||
background?: null;
|
||||
trusted: boolean;
|
||||
donationsEnabled: boolean;
|
||||
vk?: null;
|
||||
tonAddress?: null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
type ChaptersEntity = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
title?: string | null;
|
||||
number: number;
|
||||
volume: number;
|
||||
likesCount: number;
|
||||
commentsCount: number;
|
||||
donut: boolean;
|
||||
corrupted: boolean;
|
||||
createdById: string;
|
||||
branchId: string;
|
||||
bookId: string;
|
||||
createdAt: string;
|
||||
expiredAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
type RelatedEntityOrBook = {
|
||||
id: string;
|
||||
type: string;
|
||||
slug: string;
|
||||
poster: string;
|
||||
motion?: null;
|
||||
status: string;
|
||||
contentStatus: string;
|
||||
name: Name;
|
||||
country: string;
|
||||
year: number;
|
||||
formats?: null[] | null;
|
||||
viewsCount: number;
|
||||
likesCount: number;
|
||||
bookmarksCount: number;
|
||||
ratingVotesCount: number;
|
||||
averageRating: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
type AssociatedEntity = {
|
||||
id: string;
|
||||
kind: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
book: RelatedEntityOrBook;
|
||||
};
|
||||
type CoversEntity = {
|
||||
id: string;
|
||||
volume: number;
|
||||
image: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type responseChapter = {
|
||||
id: string;
|
||||
name?: string;
|
||||
title: string;
|
||||
number: number;
|
||||
volume: number;
|
||||
likesCount: number;
|
||||
commentsCount: number;
|
||||
moderationStatus: string;
|
||||
content: Content;
|
||||
pages?: PagesEntity[];
|
||||
donut: boolean;
|
||||
corrupted: boolean;
|
||||
createdById: string;
|
||||
branchId: string;
|
||||
bookId: string;
|
||||
publishers?: PublishersEntity[] | null;
|
||||
createdAt: string;
|
||||
expiredAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
type Content = {
|
||||
type: string;
|
||||
content?: ContentEntity[] | null;
|
||||
};
|
||||
type ContentEntity = {
|
||||
text?: string | null;
|
||||
type: string;
|
||||
attrs?: Attrs | null;
|
||||
content?: Content[] | null;
|
||||
};
|
||||
type Attrs = {
|
||||
textAlign?: string;
|
||||
pages?: [string];
|
||||
};
|
||||
type PagesEntity = {
|
||||
id: string;
|
||||
image: string;
|
||||
index: number;
|
||||
commentsCount: number;
|
||||
height: number;
|
||||
width: number;
|
||||
createdAt: string;
|
||||
};
|
||||
@@ -0,0 +1,936 @@
|
||||
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 dayjs from 'dayjs';
|
||||
|
||||
const statusKey: Record<string, string> = {
|
||||
'active': NovelStatus.Ongoing,
|
||||
'completed': NovelStatus.Completed,
|
||||
'freezed': NovelStatus.OnHiatus,
|
||||
'unknown': NovelStatus.Unknown,
|
||||
};
|
||||
|
||||
class TL implements Plugin.PluginBase {
|
||||
id = 'TL';
|
||||
name = 'NovelTL';
|
||||
site = 'https://novel.tl';
|
||||
version = '1.0.1';
|
||||
icon = 'src/ru/noveltl/icon.png';
|
||||
|
||||
async fetchNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
searchTerm?: string,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const { data }: response = await fetchApi(
|
||||
this.site + '/api/site/v2/graphql',
|
||||
{
|
||||
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',
|
||||
},
|
||||
Referer: this.site,
|
||||
body: JSON.stringify({
|
||||
query:
|
||||
'query Projects($hostname:String! $filter:SearchFilter $page:Int $limit:Int){projects(section:{fullUrl:$hostname}filter:$filter page:{pageSize:$limit,pageNumber:$page}){content{title fullUrl covers{url}}}}',
|
||||
variables: {
|
||||
filter: {
|
||||
query: searchTerm || undefined,
|
||||
tags: filters.tags?.value?.length
|
||||
? filters.tags.value
|
||||
: undefined,
|
||||
genres: filters.genres?.value?.length
|
||||
? filters.genres.value
|
||||
: undefined,
|
||||
},
|
||||
hostname: 'novel.tl',
|
||||
limit: 40,
|
||||
page,
|
||||
},
|
||||
}),
|
||||
},
|
||||
).then(res => res.json());
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
data?.projects?.content?.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.title,
|
||||
path: novel.fullUrl,
|
||||
cover: novel?.covers?.[0]?.url
|
||||
? this.site + novel.covers[0].url
|
||||
: defaultCover,
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
popularNovels = this.fetchNovels;
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const defaultOptions: any = {
|
||||
filters: {},
|
||||
};
|
||||
return this.fetchNovels(page, defaultOptions, searchTerm);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const { data }: response = await fetchApi(
|
||||
this.site + '/api/site/v2/graphql',
|
||||
{
|
||||
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',
|
||||
},
|
||||
Referer: this.site,
|
||||
body: JSON.stringify({
|
||||
query:
|
||||
'query Book($url:String){project(project:{fullUrl:$url}){title translationStatus fullUrl covers{url}persons(langs:["ru","en","*"],roles:["author","illustrator"]){role name{firstName lastName}}genres{nameRu nameEng}tags{nameRu nameEng}annotation{text}subprojects{content{title volumes{content{shortName chapters{title publishDate fullUrl published}}}}}}}',
|
||||
variables: {
|
||||
url: novelPath,
|
||||
},
|
||||
}),
|
||||
},
|
||||
).then(res => res.json());
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: data.project?.title || '',
|
||||
cover: data.project?.covers?.[0]?.url
|
||||
? this.site + data.project.covers[0].url
|
||||
: defaultCover,
|
||||
summary: data.project?.annotation?.text,
|
||||
status:
|
||||
statusKey[data.project?.translationStatus || 'unknown'] ||
|
||||
NovelStatus.Unknown,
|
||||
};
|
||||
|
||||
const genres = [data.project?.tags, data.project?.genres]
|
||||
.flat()
|
||||
.map(item => item?.nameRu || item?.nameEng)
|
||||
.filter(item => item);
|
||||
|
||||
if (genres?.length) {
|
||||
novel.genres = genres.join(', ');
|
||||
}
|
||||
|
||||
data.project?.persons?.forEach(person => {
|
||||
if (person.role == 'author' && person.name.firstName) {
|
||||
novel.author =
|
||||
person.name.firstName + ' ' + (person.name?.lastName || '');
|
||||
}
|
||||
if (person.role == 'illustrator' && person.name.firstName) {
|
||||
novel.artist =
|
||||
person.name.firstName + ' ' + (person.name?.lastName || '');
|
||||
}
|
||||
});
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
data.project?.subprojects?.content?.forEach(work =>
|
||||
work.volumes.content.forEach((volume, volumeIndex) =>
|
||||
volume.chapters.forEach((chapter, chapterIndex) => {
|
||||
if (chapter.published) {
|
||||
chapters.push({
|
||||
name:
|
||||
(volume.shortName || 'Том ' + (volumeIndex + 1)) +
|
||||
' ' +
|
||||
(chapter.title || 'Глава ' + (chapterIndex + 1)),
|
||||
path: chapter.fullUrl,
|
||||
releaseTime: dayjs(chapter.publishDate).format('LLL'),
|
||||
chapterNumber: chapters.length + 1,
|
||||
page: work.title || 'Основная серия',
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const { data } = await fetchApi(this.site + '/api/site/v2/graphql', {
|
||||
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',
|
||||
},
|
||||
Referer: this.site,
|
||||
body: JSON.stringify({
|
||||
query:
|
||||
'query($url:String){chapter(chapter:{fullUrl:$url}){text{text}}}',
|
||||
variables: {
|
||||
url: decodeURI(chapterPath),
|
||||
},
|
||||
}),
|
||||
}).then(res => res.json());
|
||||
|
||||
const chapterText = data.chapter?.text?.text || '';
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
resolveUrl = (path: string) => 'https://' + path;
|
||||
|
||||
filters = {
|
||||
tags: {
|
||||
label: 'Тэги',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: 'Автоматы', value: '64' },
|
||||
{ label: 'Агрессивные персонажи', value: '26' },
|
||||
{ label: 'Ад', value: '338' },
|
||||
{ label: 'Адаптация манги', value: '8' },
|
||||
{ label: 'Академия', value: '5' },
|
||||
{ label: 'Актёрское искусство', value: '7' },
|
||||
{ label: 'Актеры озвучки', value: '746' },
|
||||
{ label: 'Алхимия', value: '27' },
|
||||
{ label: 'Альтернативная реальность', value: '30' },
|
||||
{ label: 'Амнезия', value: '31' },
|
||||
{ label: 'Анабиоз', value: '170' },
|
||||
{ label: 'Анал', value: '33' },
|
||||
{ label: 'Ангелы', value: '38' },
|
||||
{ label: 'Андроиды', value: '37' },
|
||||
{ label: 'Анти-рост персонажа', value: '25' },
|
||||
{ label: 'Антигерой', value: '43' },
|
||||
{ label: 'Антикварный магазин', value: '44' },
|
||||
{ label: 'Антимагия', value: '41' },
|
||||
{ label: 'Антиутопия', value: '225' },
|
||||
{ label: 'Апатичный главный герой', value: '46' },
|
||||
{ label: 'Апокалипсис', value: '47' },
|
||||
{ label: 'Аристократия', value: '51' },
|
||||
{ label: 'Армия', value: '53' },
|
||||
{ label: 'Артефакты', value: '58' },
|
||||
{ label: 'Асоциальный главный герой', value: '42' },
|
||||
{ label: 'Ассасины', value: '61' },
|
||||
{ label: 'Атмосфера Европы', value: '245' },
|
||||
{ label: 'Банды', value: '302' },
|
||||
{ label: 'БДСМ', value: '77' },
|
||||
{ label: 'Бедный главный герой', value: '541' },
|
||||
{ label: 'Бездушный главный герой', value: '143' },
|
||||
{ label: 'Беззаботный герой', value: '115' },
|
||||
{ label: 'Безответная любовь', value: '213' },
|
||||
{ label: 'Безответная любовь', value: '740' },
|
||||
{ label: 'Безработные', value: '377' },
|
||||
{ label: 'Беременность', value: '551' },
|
||||
{ label: 'Бескорыстная любовь', value: '733' },
|
||||
{ label: 'Бессмертные', value: '359' },
|
||||
{ label: 'Бесстрашный главный герой', value: '274' },
|
||||
{ label: 'Библиотека', value: '393' },
|
||||
{ label: 'Биография', value: '86' },
|
||||
{ label: 'Близнецы', value: '729' },
|
||||
{ label: 'Богачи', value: '753' },
|
||||
{ label: 'Боги', value: '318' },
|
||||
{ label: 'Богини', value: '316' },
|
||||
{ label: 'Богоподобный протагонист', value: '314' },
|
||||
{ label: 'Боевая академия', value: '75' },
|
||||
{ label: 'Боевая парочка', value: '547' },
|
||||
{ label: 'Божественная защита', value: '208' },
|
||||
{ label: 'Божественная сила', value: '317' },
|
||||
{ label: 'Болеющие персонажи', value: '648' },
|
||||
{ label: 'Борьба за власть', value: '548' },
|
||||
{ label: 'Брак по расчету', value: '55' },
|
||||
{ label: 'Братство', value: '106' },
|
||||
{ label: 'Братья или сёстры', value: '646' },
|
||||
{ label: 'Брошенные дети', value: '1' },
|
||||
{ label: 'Бывший герой', value: '290' },
|
||||
{ label: 'Вампиры', value: '742' },
|
||||
{ label: 'Ваншот', value: '496' },
|
||||
{ label: 'Вдали от родителей', value: '3' },
|
||||
{ label: 'Ведьмы', value: '756' },
|
||||
{ label: 'Везучий главный герой', value: '411' },
|
||||
{ label: 'Верные подданные', value: '410' },
|
||||
{ label: 'Вероломные герои/плетение интриг', value: '424' },
|
||||
{ label: 'Взросление персонажа', value: '24' },
|
||||
{ label: 'Видит то, чего остальной люд не в силах', value: '617' },
|
||||
{ label: 'Виртуальная реальность', value: '744' },
|
||||
{ label: 'Владелец магазина', value: '677' },
|
||||
{ label: 'Владелец уникального оружия', value: '736' },
|
||||
{ label: 'Властные персонажи', value: '545' },
|
||||
{ label: 'Влияние прошлого', value: '517' },
|
||||
{ label: 'Внезапное обогащение', value: '691' },
|
||||
{ label: 'Внезапное обретение силы', value: '690' },
|
||||
{ label: 'Военные хроники', value: '749' },
|
||||
{ label: 'Возвращение в родной мир', value: '587' },
|
||||
{ label: 'Война', value: '750' },
|
||||
{ label: 'Вокалоид', value: '745' },
|
||||
{ label: 'Волшебные твари', value: '413' },
|
||||
{ label: 'Воскрешение', value: '586' },
|
||||
{ label: 'Воспитанный главный герой', value: '537' },
|
||||
{ label: 'Воспоминания прошлого', value: '283' },
|
||||
{ label: 'Враги становятся союзниками', value: '238' },
|
||||
{ label: 'Врата в иной мир', value: '303' },
|
||||
{ label: 'Временной парадокс', value: '711' },
|
||||
{ label: 'Вторжение на Землю', value: '228' },
|
||||
{ label: 'Второй шанс', value: '608' },
|
||||
{ label: 'Выживание', value: '695' },
|
||||
{ label: 'Вынужденные условия проживания', value: '287' },
|
||||
{ label: 'Высокомерный герой', value: '56' },
|
||||
{ label: 'Гадание', value: '207' },
|
||||
{ label: 'Галлюцинации', value: '189' },
|
||||
{ label: 'Гарем рабов', value: '656' },
|
||||
{ label: 'Геймеры', value: '301' },
|
||||
{ label: 'Генетические модификации', value: '306' },
|
||||
{ label: 'Герои', value: '341' },
|
||||
{ label: 'Герои непонятного пола', value: '36' },
|
||||
{ label: 'Героиня девушка-сорванец', value: '715' },
|
||||
{ label: 'Героиня красавица', value: '81' },
|
||||
{ label: 'Герой влюбляется первым', value: '561' },
|
||||
{ label: 'Герой использует щит', value: '636' },
|
||||
{ label: 'Герой красавец', value: '329' },
|
||||
{ label: 'Герой со множеством тел', value: '564' },
|
||||
{ label: 'Герой-бесстыдник', value: '632' },
|
||||
{ label: 'Герой-извращенец', value: '521' },
|
||||
{ label: 'Герой-трудяга', value: '330' },
|
||||
{ label: 'Герой-яндере', value: '421' },
|
||||
{ label: 'Гетерохромия', value: '342' },
|
||||
{ label: 'Гильдии', value: '324' },
|
||||
{ label: 'Гиперопека', value: '509' },
|
||||
{ label: 'Главная героиня — женщина', value: '277' },
|
||||
{ label: 'Главный герой - гений', value: '308' },
|
||||
{ label: 'Главный герой - Моб', value: '446' },
|
||||
{ label: 'Главный герой — знаменитость', value: '263' },
|
||||
{ label: 'Главный герой — мужчина', value: '419' },
|
||||
{ label: 'Главный герой в поисках гарема', value: '331' },
|
||||
{ label: 'Главный герой заранее готовится', value: '557' },
|
||||
{ label: 'Главный герой не человек', value: '488' },
|
||||
{ label: 'Главный герой приёмный', value: '20' },
|
||||
{ label: 'Главный герой сразу силён', value: '563' },
|
||||
{ label: 'Гладиаторы', value: '310' },
|
||||
{ label: 'Глухой к любви герой', value: '194' },
|
||||
{ label: 'Гоблины', value: '313' },
|
||||
{ label: 'Големы', value: '319' },
|
||||
{ label: 'Гомункул', value: '347' },
|
||||
{ label: 'Горничные', value: '418' },
|
||||
{ label: 'Государственные интриги', value: '212' },
|
||||
{ label: 'Гринд', value: '322' },
|
||||
{ label: 'Давняя травма', value: '518' },
|
||||
{ label: 'Двойник', value: '97' },
|
||||
{ label: 'Дворецкие', value: '111' },
|
||||
{ label: 'Дворфы', value: '224' },
|
||||
{ label: 'Дворяне', value: '487' },
|
||||
{ label: 'Двоюродные братья или сёстры', value: '162' },
|
||||
{ label: 'Девочки-волшебницы', value: '415' },
|
||||
{ label: 'Демоны', value: '193' },
|
||||
{ label: 'Депрессия', value: '196' },
|
||||
{ label: 'Детектив', value: '198' },
|
||||
{ label: 'Детективное расследование', value: '473' },
|
||||
{ label: 'Детское насилие', value: '125' },
|
||||
{ label: 'Детское обещание', value: '130' },
|
||||
{ label: 'Дискриминация', value: '203' },
|
||||
{ label: 'Длительный разрыв отношений', value: '400' },
|
||||
{ label: 'Домашняя жизнь', value: '45' },
|
||||
{ label: 'Драконы', value: '218' },
|
||||
{ label: 'Древний Китай', value: '34' },
|
||||
{ label: 'Дружба', value: '293' },
|
||||
{ label: 'Друзья детства', value: '128' },
|
||||
{ label: 'Друзья становятся врагами', value: '292' },
|
||||
{ label: 'Духи', value: '673' },
|
||||
{ label: 'Духовная сила', value: '665' },
|
||||
{ label: 'Духовный наставник', value: '671' },
|
||||
{ label: 'Душещипательная история', value: '335' },
|
||||
{ label: 'Души', value: '666' },
|
||||
{ label: 'Дьявольский путь культивации', value: '192' },
|
||||
{ label: 'Ёкай', value: '763' },
|
||||
{ label: 'Есть аниме-адаптация', value: '10' },
|
||||
{ label: 'Есть видеоигра', value: '13' },
|
||||
{ label: 'Есть дорама-адаптация', value: '11' },
|
||||
{ label: 'Есть манга-адаптация', value: '14' },
|
||||
{ label: 'Есть манхва-адаптация', value: '16' },
|
||||
{ label: 'Есть маньхуа-адаптация', value: '15' },
|
||||
{ label: 'Есть фильм', value: '17' },
|
||||
{ label: 'Есть CD дорама-адаптация', value: '12' },
|
||||
{ label: 'Жёсткий главный герой', value: '597' },
|
||||
{ label: 'Жестокие персонажи', value: '4' },
|
||||
{ label: 'Жёстокие персонажи', value: '169' },
|
||||
{ label: 'Животное-спутник', value: '78' },
|
||||
{ label: 'Жизнь в одиночку', value: '396' },
|
||||
{ label: 'Жрецы', value: '555' },
|
||||
{ label: 'Жрицы', value: '554' },
|
||||
{ label: 'Забота о детях', value: '127' },
|
||||
{ label: 'Заботливый главный герой', value: '116' },
|
||||
{ label: 'Загадочное заболевание', value: '471' },
|
||||
{ label: 'Загадочное прошлое родителей', value: '470' },
|
||||
{ label: 'Заговоры', value: '154' },
|
||||
{ label: 'Закалка тела', value: '96' },
|
||||
{ label: 'Закон джунглей', value: '682' },
|
||||
{ label: 'Закулисная борба', value: '603' },
|
||||
{ label: 'Замкнутый главный герой', value: '371' },
|
||||
{ label: 'Запечатанная сила', value: '607' },
|
||||
{ label: 'Затворник', value: '346' },
|
||||
{ label: 'Звери', value: '80' },
|
||||
{ label: 'Зверолюди', value: '39' },
|
||||
{ label: 'Звероподобные', value: '79' },
|
||||
{ label: 'Земледелие', value: '268' },
|
||||
{ label: 'Злобные благородные девы', value: '743' },
|
||||
{ label: 'Зловещие организации', value: '247' },
|
||||
{ label: 'Злой главный герой', value: '248' },
|
||||
{ label: 'Злые боги', value: '246' },
|
||||
{ label: 'Злые религии', value: '249' },
|
||||
{ label: 'Знаменитости', value: '118' },
|
||||
{ label: 'Знания из прошлой жизни', value: '553' },
|
||||
{ label: 'Зомби', value: '767' },
|
||||
{ label: 'Игра на выживание', value: '696' },
|
||||
{ label: 'Игровая рейтинговая система', value: '300' },
|
||||
{ label: 'Из грязи в князи', value: '542' },
|
||||
{ label: 'Известные родители', value: '262' },
|
||||
{ label: 'Изменение внешнего вида', value: '48' },
|
||||
{ label: 'Изнасилование', value: '574' },
|
||||
{ label: 'Инвалидность', value: '202' },
|
||||
{ label: 'Индустриализация', value: '364' },
|
||||
{ label: 'Инженер', value: '241' },
|
||||
{ label: 'Инцест', value: '361' },
|
||||
{ label: 'Искажённая личность', value: '730' },
|
||||
{ label: 'Искусственный интеллект', value: '59' },
|
||||
{ label: 'Исполнение желаний', value: '755' },
|
||||
{ label: 'Каннибализм', value: '113' },
|
||||
{ label: 'Карточные игры', value: '114' },
|
||||
{ label: 'Кладбищенский смотритель', value: '321' },
|
||||
{ label: 'Классика', value: '134' },
|
||||
{ label: 'Книги', value: '99' },
|
||||
{ label: 'Книги навыков', value: '654' },
|
||||
{ label: 'Книжный червь', value: '100' },
|
||||
{ label: 'Коллеги', value: '140' },
|
||||
{ label: 'Колледж/Университет', value: '145' },
|
||||
{ label: 'Командная работа', value: '701' },
|
||||
{ label: 'Комедийный подтекст', value: '147' },
|
||||
{ label: 'Комплекс брата', value: '105' },
|
||||
{ label: 'Комплекс неполноценности', value: '365' },
|
||||
{ label: 'Контракты', value: '155' },
|
||||
{ label: 'Контроль разума', value: '441' },
|
||||
{ label: 'Конфликт в семье', value: '261' },
|
||||
{ label: 'Конфликт верности', value: '153' },
|
||||
{ label: 'Копейщик', value: '668' },
|
||||
{ label: 'Королевская особа', value: '596' },
|
||||
{ label: 'Королевства', value: '382' },
|
||||
{ label: 'Короткий рассказ', value: '638' },
|
||||
{ label: 'Коррупция', value: '157' },
|
||||
{ label: 'Космические Войны', value: '158' },
|
||||
{ label: 'Космос', value: '507' },
|
||||
{ label: 'Косплей', value: '159' },
|
||||
{ label: 'Кража способностей', value: '2' },
|
||||
{ label: 'Крафтинг', value: '164' },
|
||||
{ label: 'Кризис личности', value: '357' },
|
||||
{ label: 'Кровь', value: '320' },
|
||||
{ label: 'Кружки, клубы', value: '138' },
|
||||
{ label: 'Кудэрэ', value: '384' },
|
||||
{ label: 'Кузнец', value: '90' },
|
||||
{ label: 'Кукловоды', value: '567' },
|
||||
{ label: 'Куклы/Марионетки', value: '211' },
|
||||
{ label: 'Кулинария', value: '156' },
|
||||
{ label: 'Культивация', value: '171' },
|
||||
{ label: 'Куннилингус', value: '172' },
|
||||
{ label: 'Легенды', value: '391' },
|
||||
{ label: 'Легкая жизнь', value: '229' },
|
||||
{ label: 'Лекари', value: '334' },
|
||||
{ label: 'Ленивый главный герой', value: '389' },
|
||||
{ label: 'Лидерство', value: '390' },
|
||||
{ label: 'Любит персонажа младше себя', value: '765' },
|
||||
{ label: 'Любит персонажа старше себя', value: '494' },
|
||||
{ label: 'Любовное соперничество', value: '406' },
|
||||
{ label: 'Любовные треугольники', value: '407' },
|
||||
{ label: 'Любовь детства', value: '129' },
|
||||
{ label: 'Любовь с первого взгляда', value: '404' },
|
||||
{ label: 'Любящие родители', value: '215' },
|
||||
{ label: 'Маги', value: '757' },
|
||||
{ label: 'Магические технологии', value: '417' },
|
||||
{ label: 'Магия', value: '412' },
|
||||
{ label: 'Магия призыва', value: '694' },
|
||||
{ label: 'Маленькие девочки', value: '397' },
|
||||
{ label: 'Мангака', value: '423' },
|
||||
{ label: 'Маниакальная любовь', value: '492' },
|
||||
{ label: 'Марти Сью', value: '508' },
|
||||
{ label: 'Мастер подземелий', value: '222' },
|
||||
{ label: 'Мастурбация', value: '432' },
|
||||
{ label: 'Матриархат', value: '433' },
|
||||
{ label: 'Межпространственные путешествия', value: '370' },
|
||||
{ label: 'Менеджмент', value: '422' },
|
||||
{ label: 'Месть', value: '588' },
|
||||
{ label: 'Мечи и магия', value: '697' },
|
||||
{ label: 'Мечники', value: '698' },
|
||||
{ label: 'Миленькие дети', value: '176' },
|
||||
{ label: 'Милитаризм', value: '439' },
|
||||
{ label: 'Миловидная история', value: '178' },
|
||||
{ label: 'Милый главный герой', value: '177' },
|
||||
{ label: 'Минет', value: '275' },
|
||||
{ label: 'Мировое древо', value: '760' },
|
||||
{ label: 'Миролюбивый главный герой', value: '510' },
|
||||
{ label: 'Мифология', value: '475' },
|
||||
{ label: 'Младшие сёстры', value: '766' },
|
||||
{ label: 'ММОРПГ', value: '445' },
|
||||
{ label: 'Множество временных линий', value: '463' },
|
||||
{ label: 'Модели', value: '447' },
|
||||
{ label: 'Молод не по годам', value: '49' },
|
||||
{ label: 'Монстры', value: '454' },
|
||||
{ label: 'Мрачность', value: '183' },
|
||||
{ label: 'Музыка', value: '466' },
|
||||
{ label: 'Музыкальные группы', value: '68' },
|
||||
{ label: 'Мутации', value: '468' },
|
||||
{ label: 'На волосок от смерти', value: '479' },
|
||||
{ label: 'На все руки мастер', value: '374' },
|
||||
{ label: 'Навыки имеют стоимость', value: '150' },
|
||||
{ label: 'Нагота', value: '490' },
|
||||
{ label: 'Наездники на драконах', value: '216' },
|
||||
{ label: 'Наёмники', value: '437' },
|
||||
{ label: 'Наивный главный герой', value: '476' },
|
||||
{ label: 'Наследие', value: '366' },
|
||||
{ label: 'Настоящая любовь', value: '200' },
|
||||
{ label: 'Настоящий гарем', value: '540' },
|
||||
{ label: 'Небесное испытание', value: '337' },
|
||||
{ label: 'Невезучий главный герой', value: '738' },
|
||||
{ label: 'Недоверчивый протагонист', value: '206' },
|
||||
{ label: 'Недооцененный главный герой', value: '734' },
|
||||
{ label: 'Недопонимание', value: '444' },
|
||||
{ label: 'Некромант', value: '480' },
|
||||
{ label: 'Нелинейное повествование', value: '489' },
|
||||
{ label: 'Неловкий главный герой', value: '67' },
|
||||
{ label: 'Нелюбимый протагонист', value: '333' },
|
||||
{ label: 'Немой персонаж', value: '469' },
|
||||
{ label: 'Ненадежный рассказчик', value: '739' },
|
||||
{ label: 'Непримечательный герой', value: '65' },
|
||||
{ label: 'Нерешительный протагонист', value: '363' },
|
||||
{ label: 'Несгибаемый главный герой', value: '676' },
|
||||
{ label: 'Несколько главных героев', value: '460' },
|
||||
{ label: 'Несколько миров', value: '461' },
|
||||
{ label: 'Несколько перерожденцев', value: '462' },
|
||||
{ label: 'Несколько попаданцев', value: '464' },
|
||||
{ label: 'Несравненная скорость культивации', value: '269' },
|
||||
{ label: 'Нехватка/отсутствие здравого смысла', value: '385' },
|
||||
{ label: 'Нечестный протагонист', value: '205' },
|
||||
{ label: 'НИИТ', value: '481' },
|
||||
{ label: 'Ниндзя', value: '486' },
|
||||
{ label: 'Обмен телами', value: '95' },
|
||||
{ label: 'Оборотни', value: '754' },
|
||||
{ label: 'Объект любви влюбился первым', value: '405' },
|
||||
{ label: 'Объект любви популярен', value: '543' },
|
||||
{ label: 'Огнестрельное оружие', value: '280' },
|
||||
{ label: 'Ограничение на время жизни', value: '394' },
|
||||
{ label: 'Одержимость', value: '544' },
|
||||
{ label: 'Один родитель', value: '651' },
|
||||
{ label: 'Одинокий главный герой', value: '399' },
|
||||
{ label: 'Одиночество', value: '398' },
|
||||
{ label: 'Одно тело на двоих', value: '634' },
|
||||
{ label: 'Око силы', value: '253' },
|
||||
{ label: 'Оммёдзи', value: '498' },
|
||||
{ label: 'Организованная преступность', value: '500' },
|
||||
{ label: 'Оргия', value: '501' },
|
||||
{ label: 'Орки', value: '499' },
|
||||
{ label: 'Основано на аниме', value: '74' },
|
||||
{ label: 'Основано на видеоигре', value: '72' },
|
||||
{ label: 'Основано на визуальной новелле', value: '73' },
|
||||
{ label: 'Основано на песне', value: '70' },
|
||||
{ label: 'Основано на фильме', value: '69' },
|
||||
{ label: 'Осторожный главный герой', value: '117' },
|
||||
{ label: 'От ненависти до любви один шаг', value: '239' },
|
||||
{ label: 'От сильного с сильнейшему', value: '684' },
|
||||
{ label: 'От слабого к сильному', value: '752' },
|
||||
{ label: 'Отаку', value: '503' },
|
||||
{ label: 'Отзывчивая любовь', value: '380' },
|
||||
{ label: 'Отзывчивый главный герой', value: '339' },
|
||||
{ label: 'Отношение учитель-ученик', value: '430' },
|
||||
{ label: 'Отношения бог-человек', value: '315' },
|
||||
{ label: 'Отношения между учителем и учеником', value: '687' },
|
||||
{ label: 'Отношения начальник-подчинённый', value: '101' },
|
||||
{ label: 'Отношения против воли', value: '286' },
|
||||
{ label: 'Отношения с нечеловеком', value: '353' },
|
||||
{ label: 'Отношения сэмпай-кохай', value: '621' },
|
||||
{ label: 'Отношения хозяин-слуга', value: '431' },
|
||||
{ label: 'Отомэ-игры', value: '504' },
|
||||
{ label: 'ОТП', value: '562' },
|
||||
{ label: 'Отшельники', value: '505' },
|
||||
{ label: 'Офисный работник', value: '600' },
|
||||
{ label: 'Официанты', value: '748' },
|
||||
{ label: 'Охотники', value: '355' },
|
||||
{ label: 'Падшие ангелы', value: '255' },
|
||||
{ label: 'Падшие дворяне', value: '256' },
|
||||
{ label: 'Пайзури', value: '511' },
|
||||
{ label: 'Паразиты', value: '513' },
|
||||
{ label: 'Параллельные миры', value: '512' },
|
||||
{ label: 'Парк развлечений', value: '32' },
|
||||
{ label: 'Пародия', value: '515' },
|
||||
{ label: 'Перемещение между мирами', value: '758' },
|
||||
{ label: 'Переодевание', value: '167' },
|
||||
{ label: 'Переплавка пилюль', value: '529' },
|
||||
{ label: 'Перерождение в девушку', value: '420' },
|
||||
{ label: 'Перерождение в другом мире', value: '580' },
|
||||
{ label: 'Перерождение в другом мире', value: '720' },
|
||||
{ label: 'Перерождение в игровом мире', value: '579' },
|
||||
{ label: 'Перерождение в монстра', value: '577' },
|
||||
{ label: 'Персонажи с мазохистскими наклонностями', value: '429' },
|
||||
{ label: 'Персонажи с садистскими наклонностями', value: '598' },
|
||||
{ label: 'Петля времени', value: '709' },
|
||||
{ label: 'Петтинг', value: '328' },
|
||||
{ label: 'Писатели', value: '761' },
|
||||
{ label: 'Питомцы', value: '522' },
|
||||
{ label: 'Племенное общество', value: '726' },
|
||||
{ label: 'Повелитель демонов', value: '191' },
|
||||
{ label: 'Повествование от нескольких лиц', value: '459' },
|
||||
{ label: 'Поглощение навыков', value: '653' },
|
||||
{ label: 'Подземелья', value: '223' },
|
||||
{ label: 'Поздно начинающаяся романтическая линия', value: '387' },
|
||||
{ label: 'Политика', value: '538' },
|
||||
{ label: 'Полулюди', value: '190' },
|
||||
{ label: 'Помолвка', value: '240' },
|
||||
{ label: 'Попаданец в другой мир', value: '723' },
|
||||
{ label: 'Попаданец в игровой мир', value: '722' },
|
||||
{ label: 'Постапокалипсис', value: '546' },
|
||||
{ label: 'Постижение Дао', value: '181' },
|
||||
{ label: 'Потеря девственности', value: '282' },
|
||||
{ label: 'Потустороннее пространство', value: '416' },
|
||||
{ label: 'Похищения', value: '379' },
|
||||
{ label: 'Прагматичный главный герой', value: '549' },
|
||||
{ label: 'Предательство', value: '83' },
|
||||
{ label: 'Предвидение', value: '550' },
|
||||
{ label: 'Преследователи', value: '674' },
|
||||
{ label: 'Преступление', value: '165' },
|
||||
{ label: 'Преступность', value: '166' },
|
||||
{ label: 'Приёмные дети', value: '19' },
|
||||
{ label: 'Призванный герой', value: '693' },
|
||||
{ label: 'Призраки', value: '309' },
|
||||
{ label: 'Приключения', value: '22' },
|
||||
{ label: 'Принуждение', value: '108' },
|
||||
{ label: 'Приручатель', value: '453' },
|
||||
{ label: 'Прислуга', value: '625' },
|
||||
{ label: 'Приставучий любовник', value: '136' },
|
||||
{ label: 'Притворная любовь', value: '552' },
|
||||
{ label: 'Пришельцы', value: '28' },
|
||||
{ label: 'Программист', value: '558' },
|
||||
{ label: 'Произведение - обладатель наград', value: '66' },
|
||||
{ label: 'Проклятия', value: '175' },
|
||||
{ label: 'Промывка мозгов', value: '102' },
|
||||
{ label: 'Пропуск времени', value: '712' },
|
||||
{ label: 'Пророчества', value: '559' },
|
||||
{ label: 'Протагонист в очках', value: '312' },
|
||||
{ label: 'Протагонист раб', value: '657' },
|
||||
{ label: 'Психокинез', value: '565' },
|
||||
{ label: 'Психопаты', value: '566' },
|
||||
{ label: 'Путешествия', value: '759' },
|
||||
{ label: 'Путешествия во времени', value: '713' },
|
||||
{ label: 'Пытки', value: '716' },
|
||||
{ label: 'Рабы', value: '658' },
|
||||
{ label: 'Развитие отношений', value: '160' },
|
||||
{ label: 'Развитие персонажа', value: '119' },
|
||||
{ label: 'Раздвоение личности', value: '458' },
|
||||
{ label: 'Размеренная романтика', value: '661' },
|
||||
{ label: 'Разница в статусе', value: '201' },
|
||||
{ label: 'Разорванная помолвка', value: '104' },
|
||||
{ label: 'Разумные предметы', value: '622' },
|
||||
{ label: 'Рай', value: '336' },
|
||||
{ label: 'Раняя романтика', value: '227' },
|
||||
{ label: 'Расизм', value: '573' },
|
||||
{ label: 'Расследования', value: '372' },
|
||||
{ label: 'Ребенок — главный герой', value: '126' },
|
||||
{ label: 'Реверс-гарем', value: '589' },
|
||||
{ label: 'Реверс-изнасилование', value: '590' },
|
||||
{ label: 'Резкая смена характера', value: '520' },
|
||||
{ label: 'Реинкарнация', value: '581' },
|
||||
{ label: 'Религии', value: '582' },
|
||||
{ label: 'Ресторан', value: '585' },
|
||||
{ label: 'Решительный главный герой', value: '199' },
|
||||
{ label: 'Робкий протагонист', value: '714' },
|
||||
{ label: 'Родительсткий комплекс', value: '514' },
|
||||
{ label: 'Родные не связанные кровью', value: '647' },
|
||||
{ label: 'Родословные', value: '94' },
|
||||
{ label: 'РПГ-система', value: '392' },
|
||||
{ label: 'Рыцари', value: '383' },
|
||||
{ label: 'Самовлюбленный главный герой', value: '477' },
|
||||
{ label: 'Самоубийства', value: '692' },
|
||||
{ label: 'Самураи', value: '601' },
|
||||
{ label: 'Сборник коротких историй', value: '144' },
|
||||
{ label: 'Свадьба', value: '426' },
|
||||
{ label: 'Связанные сюжетные линии', value: '369' },
|
||||
{ label: 'Святые', value: '599' },
|
||||
{ label: 'Секреты', value: '614' },
|
||||
{ label: 'Секс втроём', value: '707' },
|
||||
{ label: 'Секс-рабыня', value: '629' },
|
||||
{ label: 'Семейная любовь', value: '257' },
|
||||
{ label: 'Семь добродетелей', value: '627' },
|
||||
{ label: 'Семь смертных грехов', value: '626' },
|
||||
{ label: 'Серийные убийцы', value: '624' },
|
||||
{ label: 'Сикигами', value: '637' },
|
||||
{ label: 'Сильная любовь от старших', value: '214' },
|
||||
{ label: 'Синдром восьмиклассника', value: '132' },
|
||||
{ label: 'Синдром сестры', value: '652' },
|
||||
{ label: 'Сироты', value: '502' },
|
||||
{ label: 'Системный администратор', value: '699' },
|
||||
{ label: 'Скромный главный герой', value: '409' },
|
||||
{ label: 'Скрытые романтические отношения', value: '23' },
|
||||
{ label: 'Скрытые способности', value: '343' },
|
||||
{ label: 'Скряга', value: '450' },
|
||||
{ label: 'Скульпторы', value: '606' },
|
||||
{ label: 'Слабая романтика', value: '688' },
|
||||
{ label: 'Слабый главный герой', value: '751' },
|
||||
{ label: 'Сложные семейные отношения', value: '149' },
|
||||
{ label: 'Смена расы', value: '572' },
|
||||
{ label: 'Смертельное заболевание', value: '704' },
|
||||
{ label: 'Смерть', value: '185' },
|
||||
{ label: 'Смерть любимых', value: '186' },
|
||||
{ label: 'Современность', value: '448' },
|
||||
{ label: 'Современные знания в слаборазвитых мирах', value: '449' },
|
||||
{ label: 'Сожительство', value: '141' },
|
||||
{ label: 'Создание артефактов', value: '57' },
|
||||
{ label: 'Создание клана', value: '133' },
|
||||
{ label: 'Создание королевства', value: '381' },
|
||||
{ label: 'Создание навыков', value: '655' },
|
||||
{ label: 'Сокрытие истинных способностей', value: '344' },
|
||||
{ label: 'Сокрытие личности', value: '345' },
|
||||
{ label: 'Солдаты', value: '664' },
|
||||
{ label: 'Сон', value: '659' },
|
||||
{ label: 'Соседи по комнате', value: '595' },
|
||||
{ label: 'Социальные изгои', value: '663' },
|
||||
{ label: 'Спасение мира', value: '602' },
|
||||
{ label: 'Спорящая пара', value: '84' },
|
||||
{ label: 'Способность перевоплощения', value: '719' },
|
||||
{ label: 'Средневековье', value: '436' },
|
||||
{ label: 'Стеснительные персонажи', value: '643' },
|
||||
{ label: 'Стокгольмский синдром', value: '575' },
|
||||
{ label: 'Стокгольмский синдром', value: '675' },
|
||||
{ label: 'Стратег', value: '681' },
|
||||
{ label: 'Стратегические битвы', value: '680' },
|
||||
{ label: 'Стрелки', value: '325' },
|
||||
{ label: 'Стрельба из лука', value: '50' },
|
||||
{ label: 'Студенческий совет', value: '686' },
|
||||
{ label: 'Судьба', value: '197' },
|
||||
{ label: 'Суккубы', value: '689' },
|
||||
{ label: 'Суперспособности', value: '669' },
|
||||
{ label: 'Суровая тренировка', value: '332' },
|
||||
{ label: 'Сцены насилия', value: '195' },
|
||||
{ label: 'Таинственное прошлое', value: '472' },
|
||||
{ label: 'Тайная личность', value: '610' },
|
||||
{ label: 'Тайные организации', value: '611' },
|
||||
{ label: 'Телохранители', value: '98' },
|
||||
{ label: 'Тентакли', value: '703' },
|
||||
{ label: 'Террористы', value: '705' },
|
||||
{ label: 'Технологический разрыв', value: '702' },
|
||||
{ label: 'Тихие герои', value: '568' },
|
||||
{ label: 'Толстый протагонист', value: '271' },
|
||||
{ label: 'Торговцы', value: '438' },
|
||||
{ label: 'Травник', value: '340' },
|
||||
{ label: 'Трагичное прошлое', value: '718' },
|
||||
{ label: 'Трап', value: '725' },
|
||||
{ label: 'Триллер', value: '708' },
|
||||
{ label: 'Турнир', value: '76' },
|
||||
{ label: 'Тюрьма', value: '556' },
|
||||
{ label: 'Убийства', value: '465' },
|
||||
{ label: 'Уверенный в себе главный герой', value: '151' },
|
||||
{ label: 'Умная пара', value: '662' },
|
||||
{ label: 'Умный главный герой', value: '135' },
|
||||
{ label: 'Уникальная техника культивации', value: '735' },
|
||||
{ label: 'Уникальные оружия', value: '737' },
|
||||
{ label: 'Управление бизнесом', value: '109' },
|
||||
{ label: 'Управление временем', value: '710' },
|
||||
{ label: 'Управление пространством', value: '667' },
|
||||
{ label: 'Ускоренный рост', value: '6' },
|
||||
{ label: 'Учёные', value: '605' },
|
||||
{ label: 'Учителя', value: '700' },
|
||||
{ label: 'Фамильяры', value: '258' },
|
||||
{ label: 'Фанатизм', value: '264' },
|
||||
{ label: 'Фармацевт', value: '523' },
|
||||
{ label: 'Феи', value: '254' },
|
||||
{ label: 'Фениксы', value: '526' },
|
||||
{ label: 'Фетиш на грудь', value: '103' },
|
||||
{ label: 'Фетиш на очки', value: '311' },
|
||||
{ label: 'Философия', value: '524' },
|
||||
{ label: 'Фобии/Страхи', value: '525' },
|
||||
{ label: 'Фольклор', value: '285' },
|
||||
{ label: 'Формирование армии', value: '54' },
|
||||
{ label: 'Футанари', value: '295' },
|
||||
{ label: 'Футуристический мир', value: '296' },
|
||||
{ label: 'Фэнтези', value: '267' },
|
||||
{ label: 'Фэнтезийные существа', value: '266' },
|
||||
{ label: 'Хакеры', value: '326' },
|
||||
{ label: 'Харизматичный протагонист', value: '120' },
|
||||
{ label: 'Хитрый главный герой', value: '173' },
|
||||
{ label: 'Хладнокровный главный герой', value: '112' },
|
||||
{ label: 'Художники', value: '60' },
|
||||
{ label: 'Хулиганы', value: '188' },
|
||||
{ label: 'Цундере', value: '728' },
|
||||
{ label: 'Чат', value: '122' },
|
||||
{ label: 'Человек-оружие', value: '352' },
|
||||
{ label: 'Человекоподобный главный герой', value: '354' },
|
||||
{ label: 'Честный главный герой', value: '348' },
|
||||
{ label: 'Четкие любовные убеждения', value: '683' },
|
||||
{ label: 'Читы', value: '123' },
|
||||
{ label: 'Чудаковатые герои', value: '569' },
|
||||
{ label: 'Чужие воспоминания', value: '721' },
|
||||
{ label: 'Шантаж', value: '89' },
|
||||
{ label: 'Шеф-повар', value: '124' },
|
||||
{ label: 'Школа для девочек', value: '29' },
|
||||
{ label: 'Шоу-бизнес', value: '642' },
|
||||
{ label: 'Шпионы', value: '670' },
|
||||
{ label: 'Эволюция', value: '250' },
|
||||
{ label: 'Эгоистичный главный герой', value: '618' },
|
||||
{ label: 'Экзорцизм', value: '252' },
|
||||
{ label: 'Экономика', value: '230' },
|
||||
{ label: 'Эксгибиционизм', value: '251' },
|
||||
{ label: 'Эксперименты на людях', value: '351' },
|
||||
{ label: 'Элементальная магия', value: '234' },
|
||||
{ label: 'Элементы игрового мира', value: '299' },
|
||||
{ label: 'Элементы романтики', value: '594' },
|
||||
{ label: 'Элементы юри', value: '640' },
|
||||
{ label: 'Элементы яоя', value: '641' },
|
||||
{ label: 'Эльфы', value: '235' },
|
||||
{ label: 'Эпизодичность', value: '243' },
|
||||
{ label: 'Язвительные персонажи', value: '635' },
|
||||
{ label: 'Языковой барьер', value: '386' },
|
||||
{ label: 'Яндере', value: '762' },
|
||||
{ label: 'Яойщица', value: '294' },
|
||||
{ label: 'Японские силы самообороны', value: '378' },
|
||||
{ label: 'R-15', value: '570' },
|
||||
{ label: 'R-18', value: '571' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
genres: {
|
||||
label: 'Жанры',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: '16+', value: '1' },
|
||||
{ label: '18+', value: '2' },
|
||||
{ label: 'Боевик', value: '3' },
|
||||
{ label: 'Боевые искусства', value: '4' },
|
||||
{ label: 'Вампиры', value: '47' },
|
||||
{ label: 'Военное', value: '44' },
|
||||
{ label: 'Гарем', value: '5' },
|
||||
{ label: 'Демоны', value: '48' },
|
||||
{ label: 'Детектив', value: '6' },
|
||||
{ label: 'Дзёсей', value: '50' },
|
||||
{ label: 'Драма', value: '7' },
|
||||
{ label: 'Игры', value: '45' },
|
||||
{ label: 'Историческое', value: '8' },
|
||||
{ label: 'Киберпанк', value: '9' },
|
||||
{ label: 'Комедия', value: '10' },
|
||||
{ label: 'Космос', value: '46' },
|
||||
{ label: 'Магия', value: '42' },
|
||||
{ label: 'Махо-сёдзё', value: '12' },
|
||||
{ label: 'Меха', value: '13' },
|
||||
{ label: 'Мистика', value: '14' },
|
||||
{ label: 'Музыка', value: '49' },
|
||||
{ label: 'Научная фантастика', value: '15' },
|
||||
{ label: 'Пародия', value: '16' },
|
||||
{ label: 'Повседневность', value: '17' },
|
||||
{ label: 'Приключения', value: '18' },
|
||||
{ label: 'Психологическое', value: '19' },
|
||||
{ label: 'Романтика', value: '20' },
|
||||
{ label: 'Сверхъестественное', value: '21' },
|
||||
{ label: 'Сёдзё', value: '22' },
|
||||
{ label: 'Сёдзё-ай', value: '23' },
|
||||
{ label: 'Сёнэн', value: '24' },
|
||||
{ label: 'Сёнэн-ай', value: '25' },
|
||||
{ label: 'Смена пола', value: '26' },
|
||||
{ label: 'Современность', value: '27' },
|
||||
{ label: 'Спорт', value: '28' },
|
||||
{ label: 'Супер сила', value: '43' },
|
||||
{ label: 'Сэйнэн', value: '29' },
|
||||
{ label: 'Трагедия', value: '32' },
|
||||
{ label: 'Триллер', value: '33' },
|
||||
{ label: 'Ужасы', value: '34' },
|
||||
{ label: 'Уся', value: '35' },
|
||||
{ label: 'Фэнтези', value: '36' },
|
||||
{ label: 'Школьная жизнь', value: '37' },
|
||||
{ label: 'Этти', value: '38' },
|
||||
{ label: 'Юри', value: '39' },
|
||||
{ label: 'Яой', value: '40' },
|
||||
{ label: 'LitRPG', value: '41' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new TL();
|
||||
|
||||
type response = {
|
||||
data: Data;
|
||||
};
|
||||
|
||||
type Data = {
|
||||
projects?: Projects;
|
||||
project?: Project;
|
||||
chapter?: DataChapter;
|
||||
};
|
||||
|
||||
type DataChapter = {
|
||||
text: Text;
|
||||
};
|
||||
|
||||
type Text = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
type Project = {
|
||||
title: string;
|
||||
translationStatus: string;
|
||||
fullUrl: string;
|
||||
covers: Cover[];
|
||||
persons: Person[];
|
||||
genres: Genre[];
|
||||
tags: Genre[];
|
||||
annotation: Text;
|
||||
subprojects: Subprojects;
|
||||
};
|
||||
|
||||
type Cover = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
type Genre = {
|
||||
nameRu: string;
|
||||
nameEng: string;
|
||||
};
|
||||
|
||||
type Person = {
|
||||
role: string;
|
||||
name: Name;
|
||||
};
|
||||
|
||||
type Name = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
|
||||
type Subprojects = {
|
||||
content: SubprojectsContent[];
|
||||
};
|
||||
|
||||
type SubprojectsContent = {
|
||||
title: null | string;
|
||||
volumes: Volumes;
|
||||
};
|
||||
|
||||
type Volumes = {
|
||||
content: VolumesContent[];
|
||||
};
|
||||
|
||||
type VolumesContent = {
|
||||
shortName: null | string;
|
||||
chapters: ChapterElement[];
|
||||
};
|
||||
|
||||
type ChapterElement = {
|
||||
title: null | string;
|
||||
publishDate: Date;
|
||||
fullUrl: string;
|
||||
published: boolean;
|
||||
};
|
||||
|
||||
type Projects = {
|
||||
content: ProjectsContent[];
|
||||
};
|
||||
|
||||
type ProjectsContent = {
|
||||
title: string;
|
||||
fullUrl: string;
|
||||
covers: Cover[];
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,783 @@
|
||||
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 { storage, localStorage } from '@/lib/storage';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const statusKey: Record<number, string> = {
|
||||
1: NovelStatus.Ongoing,
|
||||
2: NovelStatus.Completed,
|
||||
3: NovelStatus.OnHiatus,
|
||||
4: NovelStatus.Cancelled,
|
||||
};
|
||||
|
||||
class RLIB implements Plugin.PluginBase {
|
||||
id = 'RLIB';
|
||||
name = 'RanobeLib';
|
||||
site = 'https://ranobelib.me';
|
||||
apiSite = 'https://api.cdnlibs.org/api/manga/';
|
||||
version = '2.2.1';
|
||||
icon = 'src/ru/ranobelib/icon.png';
|
||||
webStorageUtilized = true;
|
||||
imageRequestInit = {
|
||||
headers: {
|
||||
Accept:
|
||||
'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||
Referer: this.site,
|
||||
},
|
||||
};
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.apiSite + '?site_id[0]=3&page=' + pageNo;
|
||||
url +=
|
||||
'&sort_by=' +
|
||||
(showLatestNovels
|
||||
? 'last_chapter_at'
|
||||
: filters?.sort_by?.value || 'rating_score');
|
||||
url += '&sort_type=' + (filters?.sort_type?.value || 'desc');
|
||||
|
||||
if (filters?.require_chapters?.value) {
|
||||
url += '&chapters[min]=1';
|
||||
}
|
||||
if (filters?.types?.value?.length) {
|
||||
url += '&types[]=' + filters.types.value.join('&types[]=');
|
||||
}
|
||||
if (filters?.scanlateStatus?.value?.length) {
|
||||
url +=
|
||||
'&scanlateStatus[]=' +
|
||||
filters.scanlateStatus.value.join('&scanlateStatus[]=');
|
||||
}
|
||||
if (filters?.manga_status?.value?.length) {
|
||||
url +=
|
||||
'&manga_status[]=' +
|
||||
filters.manga_status.value.join('&manga_status[]=');
|
||||
}
|
||||
|
||||
if (filters?.genres) {
|
||||
if (filters.genres.value?.include?.length) {
|
||||
url += '&genres[]=' + filters.genres.value.include.join('&genres[]=');
|
||||
}
|
||||
if (filters.genres.value?.exclude?.length) {
|
||||
url +=
|
||||
'&genres_exclude[]=' +
|
||||
filters.genres.value.exclude.join('&genres_exclude[]=');
|
||||
}
|
||||
}
|
||||
if (filters?.tags) {
|
||||
if (filters.tags.value?.include?.length) {
|
||||
url += '&tags[]=' + filters.tags.value.include.join('&tags[]=');
|
||||
}
|
||||
if (filters.tags.value?.exclude?.length) {
|
||||
url +=
|
||||
'&tags_exclude[]=' +
|
||||
filters.tags.value.exclude.join('&tags_exclude[]=');
|
||||
}
|
||||
}
|
||||
|
||||
const result: TopLevel = await fetchApi(url, {
|
||||
headers: this.user?.token,
|
||||
}).then(res => res.json());
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
if (result.data instanceof Array) {
|
||||
result.data.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.rus_name || novel.eng_name || novel.name,
|
||||
cover: novel.cover?.default || defaultCover,
|
||||
path: novel.slug_url || novel.id + '--' + novel.slug,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const { data }: { data: DataClass } = await fetchApi(
|
||||
`${this.apiSite}${novelPath}?fields[]=summary&fields[]=genres&fields[]=tags&fields[]=teams&fields[]=authors&fields[]=status_id&fields[]=artists`,
|
||||
{ headers: { ...this.user?.token, 'Site-Id': '3' } },
|
||||
).then(res => res.json());
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: data.rus_name || data.name,
|
||||
cover: data.cover?.default || defaultCover,
|
||||
summary: data.summary?.trim(),
|
||||
};
|
||||
|
||||
if (data.status?.id) {
|
||||
novel.status = statusKey[data.status.id] || NovelStatus.Unknown;
|
||||
}
|
||||
|
||||
if (data.authors?.length) {
|
||||
novel.author = data.authors[0].name;
|
||||
}
|
||||
if (data.artists?.length) {
|
||||
novel.artist = data.artists[0].name;
|
||||
}
|
||||
|
||||
const genres = [data.genres || [], data.tags || []]
|
||||
.flat()
|
||||
.map(genres => genres?.name)
|
||||
.filter(genres => genres);
|
||||
if (genres.length) {
|
||||
novel.genres = genres.join(', ');
|
||||
}
|
||||
|
||||
const branch_name: Record<string, string> = data.teams?.reduce(
|
||||
(acc, { name, details }) => {
|
||||
acc[String(details?.branch_id ?? '0')] = name;
|
||||
return acc;
|
||||
},
|
||||
{ '0': 'Главная страница' } as Record<string, string>,
|
||||
) || { '0': 'Главная страница' };
|
||||
|
||||
const chaptersJSON: { data: DataChapter[] } = await fetchApi(
|
||||
`${this.apiSite}${novelPath}/chapters`,
|
||||
{ headers: this.user?.token },
|
||||
).then(res => res.json());
|
||||
|
||||
if (chaptersJSON.data?.length) {
|
||||
let chapters: Plugin.ChapterItem[] = chaptersJSON.data.flatMap(chapter =>
|
||||
chapter.branches.map(({ branch_id, created_at }) => {
|
||||
const bId = String(branch_id ?? '0');
|
||||
return {
|
||||
name: `Том ${chapter.volume} Глава ${chapter.number}${
|
||||
chapter.name ? ' ' + chapter.name.trim() : ''
|
||||
}`,
|
||||
path: `${novelPath}/${chapter.volume}/${chapter.number}/${bId}`,
|
||||
releaseTime: created_at ? dayjs(created_at).format('LLL') : null,
|
||||
chapterNumber: chapter.index,
|
||||
page: branch_name[bId] || 'Неизвестный',
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (chapters.length) {
|
||||
const uniquePages = new Set(chapters.map(c => c.page));
|
||||
|
||||
if (uniquePages.size === 1) {
|
||||
// If only one unique page value, set page to undefined for all chapters
|
||||
// Need more investigation one app side for single page shenanigans
|
||||
chapters = chapters.map(chapter => ({
|
||||
...chapter,
|
||||
page: undefined,
|
||||
}));
|
||||
} else if (data.teams?.length > 1) {
|
||||
// Original sorting logic for multiple pages, for reasons chapters overlap with one another
|
||||
chapters.sort((chapterA, chapterB) => {
|
||||
if (
|
||||
chapterA.page &&
|
||||
chapterB.page &&
|
||||
chapterA.page !== chapterB.page
|
||||
) {
|
||||
return chapterA.page.localeCompare(chapterB.page);
|
||||
}
|
||||
return (
|
||||
(chapterA.chapterNumber || 0) - (chapterB.chapterNumber || 0)
|
||||
);
|
||||
});
|
||||
}
|
||||
novel.chapters = chapters;
|
||||
}
|
||||
}
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const [slug, volume, number, branch_id] = chapterPath.split('/');
|
||||
let chapterText = '';
|
||||
|
||||
if (slug && volume && number) {
|
||||
const result: { data: DataClass } = await fetchApi(
|
||||
this.apiSite +
|
||||
slug +
|
||||
'/chapter?' +
|
||||
(branch_id ? 'branch_id=' + branch_id + '&' : '') +
|
||||
'number=' +
|
||||
number +
|
||||
'&volume=' +
|
||||
volume,
|
||||
{ headers: this.user?.token },
|
||||
).then(res => res.json());
|
||||
chapterText =
|
||||
result?.data?.content?.type == 'doc'
|
||||
? jsonToHtml(
|
||||
result.data.content.content,
|
||||
result.data.attachments || [],
|
||||
)
|
||||
: result?.data?.content;
|
||||
}
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const url = this.apiSite + '?site_id[0]=3&q=' + searchTerm;
|
||||
const result: TopLevel = await fetchApi(url, {
|
||||
headers: this.user?.token,
|
||||
}).then(res => res.json());
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
if (result.data instanceof Array) {
|
||||
result.data.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.rus_name || novel.eng_name || novel.name,
|
||||
cover: novel.cover?.default || defaultCover,
|
||||
path: novel.slug_url || novel.id + '--' + novel.slug,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
resolveUrl = (path: string, isNovel?: boolean) => {
|
||||
const ui = this.user?.ui ? 'ui=' + this.user.ui : '';
|
||||
|
||||
if (isNovel) return this.site + '/ru/book/' + path + (ui ? '?' + ui : '');
|
||||
|
||||
const [slug, volume, number, branch_id] = path.split('/');
|
||||
const chapterPath =
|
||||
slug +
|
||||
'/read/v' +
|
||||
volume +
|
||||
'/c' +
|
||||
number +
|
||||
(branch_id ? '?bid=' + branch_id : '');
|
||||
|
||||
return (
|
||||
this.site +
|
||||
'/ru/' +
|
||||
chapterPath +
|
||||
(ui ? (branch_id ? '&' : '?') + ui : '')
|
||||
);
|
||||
};
|
||||
|
||||
getUser = () => {
|
||||
const user = storage.get('user');
|
||||
if (user) {
|
||||
return { token: { Authorization: 'Bearer ' + user.token }, ui: user.id };
|
||||
}
|
||||
const dataRaw = localStorage.get()?.auth;
|
||||
if (!dataRaw) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const data = JSON.parse(dataRaw) as authorization;
|
||||
if (!data?.token?.access_token) return;
|
||||
storage.set(
|
||||
'user',
|
||||
{
|
||||
id: data.auth.id,
|
||||
token: data.token.access_token,
|
||||
},
|
||||
data.token.timestamp + data.token.expires_in, //the token is valid for about 7 days
|
||||
);
|
||||
return {
|
||||
token: { Authorization: 'Bearer ' + data.token.access_token },
|
||||
ui: data.auth.id,
|
||||
};
|
||||
};
|
||||
user = this.getUser(); //To change the account, you need to restart the application
|
||||
|
||||
filters = {
|
||||
sort_by: {
|
||||
label: 'Сортировка',
|
||||
value: 'rating_score',
|
||||
options: [
|
||||
{ label: 'По рейтингу', value: 'rate_avg' },
|
||||
{ label: 'По популярности', value: 'rating_score' },
|
||||
{ label: 'По просмотрам', value: 'views' },
|
||||
{ label: 'Количеству глав', value: 'chap_count' },
|
||||
{ label: 'Дате обновления', value: 'last_chapter_at' },
|
||||
{ label: 'Дате добавления', value: 'created_at' },
|
||||
{ label: 'По названию (A-Z)', value: 'name' },
|
||||
{ label: 'По названию (А-Я)', value: 'rus_name' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
sort_type: {
|
||||
label: 'Порядок',
|
||||
value: 'desc',
|
||||
options: [
|
||||
{ label: 'По убыванию', value: 'desc' },
|
||||
{ label: 'По возрастанию', value: 'asc' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
types: {
|
||||
label: 'Тип',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: 'Япония', value: '10' },
|
||||
{ label: 'Корея', value: '11' },
|
||||
{ label: 'Китай', value: '12' },
|
||||
{ label: 'Английский', value: '13' },
|
||||
{ label: 'Авторский', value: '14' },
|
||||
{ label: 'Фанфик', value: '15' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
scanlateStatus: {
|
||||
label: 'Статус перевода',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: 'Продолжается', value: '1' },
|
||||
{ label: 'Завершен', value: '2' },
|
||||
{ label: 'Заморожен', value: '3' },
|
||||
{ label: 'Заброшен', value: '4' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
manga_status: {
|
||||
label: 'Статус тайтла',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: 'Онгоинг', value: '1' },
|
||||
{ label: 'Завершён', value: '2' },
|
||||
{ label: 'Анонс', value: '3' },
|
||||
{ label: 'Приостановлен', value: '4' },
|
||||
{ label: 'Выпуск прекращён', value: '5' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
genres: {
|
||||
label: 'Жанры',
|
||||
value: { include: [], exclude: [] },
|
||||
options: [
|
||||
{ label: 'Арт', value: '32' },
|
||||
{ label: 'Безумие', value: '91' },
|
||||
{ label: 'Боевик', value: '34' },
|
||||
{ label: 'Боевые искусства', value: '35' },
|
||||
{ label: 'Вампиры', value: '36' },
|
||||
{ label: 'Военное', value: '89' },
|
||||
{ label: 'Гарем', value: '37' },
|
||||
{ label: 'Гендерная интрига', value: '38' },
|
||||
{ label: 'Героическое фэнтези', value: '39' },
|
||||
{ label: 'Демоны', value: '81' },
|
||||
{ label: 'Детектив', value: '40' },
|
||||
{ label: 'Детское', value: '88' },
|
||||
{ label: 'Дзёсэй', value: '41' },
|
||||
{ label: 'Драма', value: '43' },
|
||||
{ label: 'Игра', value: '44' },
|
||||
{ label: 'Исекай', value: '79' },
|
||||
{ label: 'История', value: '45' },
|
||||
{ label: 'Киберпанк', value: '46' },
|
||||
{ label: 'Кодомо', value: '76' },
|
||||
{ label: 'Комедия', value: '47' },
|
||||
{ label: 'Космос', value: '83' },
|
||||
{ label: 'Магия', value: '85' },
|
||||
{ label: 'Махо-сёдзё', value: '48' },
|
||||
{ label: 'Машины', value: '90' },
|
||||
{ label: 'Меха', value: '49' },
|
||||
{ label: 'Мистика', value: '50' },
|
||||
{ label: 'Музыка', value: '80' },
|
||||
{ label: 'Научная фантастика', value: '51' },
|
||||
{ label: 'Омегаверс', value: '77' },
|
||||
{ label: 'Пародия', value: '86' },
|
||||
{ label: 'Повседневность', value: '52' },
|
||||
{ label: 'Полиция', value: '82' },
|
||||
{ label: 'Постапокалиптика', value: '53' },
|
||||
{ label: 'Приключения', value: '54' },
|
||||
{ label: 'Психология', value: '55' },
|
||||
{ label: 'Романтика', value: '56' },
|
||||
{ label: 'Самурайский боевик', value: '57' },
|
||||
{ label: 'Сверхъестественное', value: '58' },
|
||||
{ label: 'Сёдзё', value: '59' },
|
||||
{ label: 'Сёдзё-ай', value: '60' },
|
||||
{ label: 'Сёнэн', value: '61' },
|
||||
{ label: 'Сёнэн-ай', value: '62' },
|
||||
{ label: 'Спорт', value: '63' },
|
||||
{ label: 'Супер сила', value: '87' },
|
||||
{ label: 'Сэйнэн', value: '64' },
|
||||
{ label: 'Трагедия', value: '65' },
|
||||
{ label: 'Триллер', value: '66' },
|
||||
{ label: 'Ужасы', value: '67' },
|
||||
{ label: 'Фантастика', value: '68' },
|
||||
{ label: 'Фэнтези', value: '69' },
|
||||
{ label: 'Хентай', value: '84' },
|
||||
{ label: 'Школа', value: '70' },
|
||||
{ label: 'Эротика', value: '71' },
|
||||
{ label: 'Этти', value: '72' },
|
||||
{ label: 'Юри', value: '73' },
|
||||
{ label: 'Яой', value: '74' },
|
||||
],
|
||||
type: FilterTypes.ExcludableCheckboxGroup,
|
||||
},
|
||||
tags: {
|
||||
label: 'Теги',
|
||||
value: { include: [], exclude: [] },
|
||||
options: [
|
||||
{ label: 'Авантюристы', value: '328' },
|
||||
{ label: 'Антигерой', value: '175' },
|
||||
{ label: 'Бессмертные', value: '333' },
|
||||
{ label: 'Боги', value: '218' },
|
||||
{ label: 'Борьба за власть', value: '309' },
|
||||
{ label: 'Брат и сестра', value: '360' },
|
||||
{ label: 'Ведьма', value: '339' },
|
||||
{ label: 'Видеоигры', value: '204' },
|
||||
{ label: 'Виртуальная реальность', value: '214' },
|
||||
{ label: 'Владыка демонов', value: '349' },
|
||||
{ label: 'Военные', value: '198' },
|
||||
{ label: 'Воспоминания из другого мира', value: '310' },
|
||||
{ label: 'Выживание', value: '212' },
|
||||
{ label: 'ГГ женщина', value: '294' },
|
||||
{ label: 'ГГ имба', value: '292' },
|
||||
{ label: 'ГГ мужчина', value: '295' },
|
||||
{ label: 'ГГ не ояш', value: '325' },
|
||||
{ label: 'ГГ не человек', value: '331' },
|
||||
{ label: 'ГГ ояш', value: '326' },
|
||||
{ label: 'Главный герой бог', value: '324' },
|
||||
{ label: 'Глупый ГГ', value: '298' },
|
||||
{ label: 'Горничные', value: '171' },
|
||||
{ label: 'Гуро', value: '306' },
|
||||
{ label: 'Гяру', value: '197' },
|
||||
{ label: 'Демоны', value: '157' },
|
||||
{ label: 'Драконы', value: '313' },
|
||||
{ label: 'Древний мир', value: '317' },
|
||||
{ label: 'Зверолюди', value: '163' },
|
||||
{ label: 'Зомби', value: '155' },
|
||||
{ label: 'Исторические фигуры', value: '323' },
|
||||
{ label: 'Кулинария', value: '158' },
|
||||
{ label: 'Культивация', value: '161' },
|
||||
{ label: 'ЛГБТ', value: '344' },
|
||||
{ label: 'ЛитРПГ', value: '319' },
|
||||
{ label: 'Лоли', value: '206' },
|
||||
{ label: 'Магия', value: '170' },
|
||||
{ label: 'Машинный перевод', value: '345' },
|
||||
{ label: 'Медицина', value: '159' },
|
||||
{ label: 'Межгалактическая война', value: '330' },
|
||||
{ label: 'Монстр Девушки', value: '207' },
|
||||
{ label: 'Монстры', value: '208' },
|
||||
{ label: 'Мрачный мир', value: '316' },
|
||||
{ label: 'Музыка', value: '358' },
|
||||
{ label: 'Музыка', value: '209' },
|
||||
{ label: 'Ниндзя', value: '199' },
|
||||
{ label: 'Обратный Гарем', value: '210' },
|
||||
{ label: 'Офисные Работники', value: '200' },
|
||||
{ label: 'Пираты', value: '341' },
|
||||
{ label: 'Подземелья', value: '314' },
|
||||
{ label: 'Политика', value: '311' },
|
||||
{ label: 'Полиция', value: '201' },
|
||||
{ label: 'Преступники / Криминал', value: '205' },
|
||||
{ label: 'Призраки / Духи', value: '196' },
|
||||
{ label: 'Призыватели', value: '329' },
|
||||
{ label: 'Прыжки между мирами', value: '321' },
|
||||
{ label: 'Путешествие в другой мир', value: '318' },
|
||||
{ label: 'Путешествие во времени', value: '213' },
|
||||
{ label: 'Рабы', value: '355' },
|
||||
{ label: 'Ранги силы', value: '312' },
|
||||
{ label: 'Реинкарнация', value: '154' },
|
||||
{ label: 'Самураи', value: '202' },
|
||||
{ label: 'Скрытие личности', value: '315' },
|
||||
{ label: 'Средневековье', value: '174' },
|
||||
{ label: 'Традиционные игры', value: '203' },
|
||||
{ label: 'Умный ГГ', value: '303' },
|
||||
{ label: 'Характерный рост', value: '332' },
|
||||
{ label: 'Хикикомори', value: '167' },
|
||||
{ label: 'Эволюция', value: '322' },
|
||||
{ label: 'Элементы РПГ', value: '327' },
|
||||
{ label: 'Эльфы', value: '217' },
|
||||
{ label: 'Якудза', value: '165' },
|
||||
],
|
||||
type: FilterTypes.ExcludableCheckboxGroup,
|
||||
},
|
||||
require_chapters: {
|
||||
label: 'Только проекты с главами',
|
||||
value: true,
|
||||
type: FilterTypes.Switch,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new RLIB();
|
||||
|
||||
function jsonToHtml(json: HTML[], images: Attachment[], html = '') {
|
||||
json.forEach(element => {
|
||||
switch (element.type) {
|
||||
case 'hardBreak':
|
||||
html += '<br>';
|
||||
break;
|
||||
case 'horizontalRule':
|
||||
html += '<hr>';
|
||||
break;
|
||||
case 'image':
|
||||
if (element.attrs?.images?.length) {
|
||||
element.attrs.images.forEach(
|
||||
({ image }: { image: string | number }) => {
|
||||
const file = images.find(
|
||||
(f: Attachment) => f.name == image || f.id == image,
|
||||
);
|
||||
if (file) {
|
||||
html += `<img src='${file.url}'>`;
|
||||
}
|
||||
},
|
||||
);
|
||||
} else if (element.attrs) {
|
||||
const attrs = Object.entries(element.attrs)
|
||||
.filter(attr => attr?.[1])
|
||||
.map(attr => `${attr[0]}="${attr[1]}"`);
|
||||
html += '<img ' + attrs.join('; ') + '>';
|
||||
}
|
||||
break;
|
||||
case 'paragraph':
|
||||
html +=
|
||||
'<p>' +
|
||||
(element.content ? jsonToHtml(element.content, images) : '<br>') +
|
||||
'</p>';
|
||||
break;
|
||||
case 'orderedList':
|
||||
html +=
|
||||
'<ol>' +
|
||||
(element.content ? jsonToHtml(element.content, images) : '<br>') +
|
||||
'</ol>';
|
||||
break;
|
||||
case 'listItem':
|
||||
html +=
|
||||
'<li>' +
|
||||
(element.content ? jsonToHtml(element.content, images) : '<br>') +
|
||||
'</li>';
|
||||
break;
|
||||
case 'blockquote':
|
||||
html +=
|
||||
'<blockquote>' +
|
||||
(element.content ? jsonToHtml(element.content, images) : '<br>') +
|
||||
'</blockquote>';
|
||||
break;
|
||||
case 'italic':
|
||||
html +=
|
||||
'<i>' +
|
||||
(element.content ? jsonToHtml(element.content, images) : '<br>') +
|
||||
'</i>';
|
||||
break;
|
||||
case 'bold':
|
||||
html +=
|
||||
'<b>' +
|
||||
(element.content ? jsonToHtml(element.content, images) : '<br>') +
|
||||
'</b>';
|
||||
break;
|
||||
case 'underline':
|
||||
html +=
|
||||
'<u>' +
|
||||
(element.content ? jsonToHtml(element.content, images) : '<br>') +
|
||||
'</u>';
|
||||
break;
|
||||
case 'heading':
|
||||
html +=
|
||||
'<h2>' +
|
||||
(element.content ? jsonToHtml(element.content, images) : '<br>') +
|
||||
'</h2>';
|
||||
break;
|
||||
case 'text':
|
||||
html += element.text;
|
||||
break;
|
||||
default:
|
||||
html += JSON.stringify(element, null, '\t'); //maybe I missed something.
|
||||
break;
|
||||
}
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
type HTML = {
|
||||
type: string;
|
||||
content?: HTML[];
|
||||
attrs?: Attrs;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
type Attrs = {
|
||||
src?: string;
|
||||
alt?: string | null;
|
||||
title?: string | null;
|
||||
images?: { image: string | number }[];
|
||||
};
|
||||
|
||||
type authorization = {
|
||||
token: Token;
|
||||
auth: Auth;
|
||||
timestamp: number;
|
||||
};
|
||||
type Token = {
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
timestamp: number;
|
||||
};
|
||||
type Auth = {
|
||||
id: number;
|
||||
username: string;
|
||||
avatar: Cover;
|
||||
last_online_at: string;
|
||||
metadata: Metadata;
|
||||
};
|
||||
type Metadata = {
|
||||
auth_domains: string;
|
||||
};
|
||||
|
||||
type TopLevel = {
|
||||
data: DataClass | DataClass[];
|
||||
links?: Links;
|
||||
meta?: Meta;
|
||||
};
|
||||
|
||||
type AgeRestriction = {
|
||||
id: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type Branch = {
|
||||
id: number;
|
||||
branch_id: null;
|
||||
created_at: string;
|
||||
teams: BranchTeam[];
|
||||
user: User;
|
||||
};
|
||||
|
||||
type BranchTeam = {
|
||||
id: number;
|
||||
slug: string;
|
||||
slug_url: string;
|
||||
model: string;
|
||||
name: string;
|
||||
cover: Cover;
|
||||
};
|
||||
|
||||
type Cover = {
|
||||
filename: null | string;
|
||||
thumbnail: string;
|
||||
default: string;
|
||||
};
|
||||
|
||||
type User = {
|
||||
username: string;
|
||||
id: number;
|
||||
};
|
||||
|
||||
type DataClass = {
|
||||
id: number;
|
||||
name: string;
|
||||
rus_name?: string;
|
||||
eng_name?: string;
|
||||
slug: string;
|
||||
slug_url?: string;
|
||||
cover?: Cover;
|
||||
ageRestriction?: AgeRestriction;
|
||||
site?: number;
|
||||
type: string;
|
||||
summary?: string;
|
||||
is_licensed?: boolean;
|
||||
teams: DataTeam[];
|
||||
genres?: Genre[];
|
||||
tags?: Genre[];
|
||||
authors?: Artist[];
|
||||
model?: string;
|
||||
status?: AgeRestriction;
|
||||
scanlateStatus?: AgeRestriction;
|
||||
artists?: Artist[];
|
||||
releaseDateString?: string;
|
||||
volume?: string;
|
||||
number?: string;
|
||||
number_secondary?: string;
|
||||
branch_id?: null;
|
||||
manga_id?: number;
|
||||
created_at?: string;
|
||||
moderated?: AgeRestriction;
|
||||
likes_count?: number;
|
||||
content?: any;
|
||||
attachments?: Attachment[];
|
||||
};
|
||||
|
||||
type Artist = {
|
||||
id: number;
|
||||
slug: string;
|
||||
slug_url: string;
|
||||
model: string;
|
||||
name: string;
|
||||
rus_name: null;
|
||||
alt_name: null;
|
||||
cover: Cover;
|
||||
subscription: Subscription;
|
||||
confirmed: null;
|
||||
user_id: number;
|
||||
titles_count_details: null;
|
||||
};
|
||||
|
||||
type Subscription = {
|
||||
is_subscribed: boolean;
|
||||
source_type: string;
|
||||
source_id: number;
|
||||
relation: null;
|
||||
};
|
||||
|
||||
type Attachment = {
|
||||
id?: string | null; // Allow null for id
|
||||
filename: string;
|
||||
name: string;
|
||||
extension: string;
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type Genre = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type DataTeam = {
|
||||
id: number;
|
||||
slug: string;
|
||||
slug_url: string;
|
||||
model: string;
|
||||
name: string;
|
||||
cover: Cover;
|
||||
details?: Details;
|
||||
vk?: string;
|
||||
discord?: null;
|
||||
};
|
||||
|
||||
type Details = {
|
||||
branch_id: null;
|
||||
is_active: boolean;
|
||||
subscriptions_count: null;
|
||||
};
|
||||
|
||||
type Links = {
|
||||
first: string;
|
||||
last: null;
|
||||
prev: null;
|
||||
next: string;
|
||||
};
|
||||
|
||||
type Meta = {
|
||||
current_page?: number;
|
||||
from?: number;
|
||||
path?: string;
|
||||
per_page?: number;
|
||||
to?: number;
|
||||
page?: number;
|
||||
has_next_page?: boolean;
|
||||
seed?: string;
|
||||
country?: string;
|
||||
};
|
||||
|
||||
type DataChapter = {
|
||||
id: number;
|
||||
index: number;
|
||||
item_number: number;
|
||||
volume: string;
|
||||
number: string;
|
||||
number_secondary: string;
|
||||
name: string;
|
||||
branches_count: number;
|
||||
branches: Branch[];
|
||||
};
|
||||
@@ -0,0 +1,405 @@
|
||||
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 dayjs from 'dayjs';
|
||||
|
||||
const regex =
|
||||
/<script id="__NEXT_DATA__" type="application\/json">(\{.*?\})<\/script>/;
|
||||
|
||||
class RNRF implements Plugin.PluginBase {
|
||||
id = 'RNRF';
|
||||
name = 'РанобэРФ';
|
||||
site = 'https://ранобэ.рф';
|
||||
version = '1.0.1';
|
||||
icon = 'src/ru/ranoberf/icon.png';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ showLatestNovels, filters }: Plugin.PopularNovelsOptions,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site + '/books?order=';
|
||||
url += showLatestNovels
|
||||
? 'lastPublishedChapter'
|
||||
: filters?.sort?.value || 'popular';
|
||||
url += '&page=' + pageNo;
|
||||
|
||||
const body = await fetchApi(url).then(res => res.text());
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
const jsonRaw = body.match(regex);
|
||||
if (jsonRaw instanceof Array && jsonRaw[1]) {
|
||||
const json: response = JSON.parse(jsonRaw[1]);
|
||||
|
||||
json.props.pageProps?.totalData?.items?.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.title,
|
||||
cover: novel?.verticalImage?.url
|
||||
? this.site + novel.verticalImage.url
|
||||
: defaultCover,
|
||||
path: '/' + novel.slug,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const body = await fetchApi(this.site + novelPath).then(res => res.text());
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: '',
|
||||
};
|
||||
|
||||
const jsonRaw = body.match(regex);
|
||||
if (jsonRaw instanceof Array && jsonRaw[1]) {
|
||||
const book: PagePropsBook = JSON.parse(jsonRaw[1]).props.pageProps.book;
|
||||
|
||||
novel.name = book.title;
|
||||
novel.summary = book.description;
|
||||
|
||||
novel.cover = book.verticalImage?.url
|
||||
? this.site + book.verticalImage.url
|
||||
: defaultCover;
|
||||
novel.status = book?.additionalInfo.includes('Активен')
|
||||
? NovelStatus.Ongoing
|
||||
: NovelStatus.Completed;
|
||||
|
||||
if (book.author) novel.author = book.author;
|
||||
if (book.genres?.length)
|
||||
novel.genres = book?.genres.map(item => item.title).join(',');
|
||||
|
||||
if (book.chapters?.length) {
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
book.chapters.forEach((chapter, chapterIndex) => {
|
||||
if (!chapter.isDonate || chapter.isUserPaid) {
|
||||
chapters.push({
|
||||
name: chapter.title,
|
||||
path: chapter.url,
|
||||
releaseTime: dayjs(chapter.publishedAt).format('LLL'),
|
||||
chapterNumber: book.chapters.length - chapterIndex,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
novel.chapters = chapters.reverse();
|
||||
}
|
||||
}
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchApi(this.site + chapterPath).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
|
||||
const jsonRaw = body.match(regex);
|
||||
if (jsonRaw instanceof Array && jsonRaw[1]) {
|
||||
const chapter: string =
|
||||
JSON.parse(jsonRaw[1])?.props?.pageProps?.chapter?.content?.text || '';
|
||||
|
||||
return chapter;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}/v3/books?filter[or][0][title][like]=${searchTerm}&filter[or][1][titleEn][like]=${searchTerm}&filter[or][2][fullTitle][like]=${searchTerm}&filter[status][]=active&filter[status][]=abandoned&filter[status][]=completed&expand=verticalImage`;
|
||||
const { items }: { items: Item[] } = await fetchApi(url).then(res =>
|
||||
res.json(),
|
||||
);
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
items.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.title,
|
||||
cover: novel?.verticalImage?.url
|
||||
? this.site + novel.verticalImage.url
|
||||
: defaultCover,
|
||||
path: '/' + novel.slug,
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Сортировка',
|
||||
value: 'popular',
|
||||
options: [
|
||||
{ label: 'Рейтинг', value: 'popular' },
|
||||
{ label: 'Дате добавления', value: 'new' },
|
||||
{ label: 'Дате обновления', value: 'lastPublishedChapter' },
|
||||
{ label: 'Законченные', value: 'completed' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new RNRF();
|
||||
|
||||
type response = {
|
||||
props: Props;
|
||||
page: string;
|
||||
query: Query;
|
||||
buildId: string;
|
||||
isFallback: boolean;
|
||||
gssp: boolean;
|
||||
appGip: boolean;
|
||||
dynamicIds?: string[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
pageProps: PageProps;
|
||||
isMobile: boolean;
|
||||
theme: string;
|
||||
__N_SSP: boolean;
|
||||
};
|
||||
|
||||
type PageProps = {
|
||||
initialReduxState: InitialReduxState;
|
||||
book?: PagePropsBook;
|
||||
totalData?: TotalData;
|
||||
layoutViewCookies?: string;
|
||||
displayBanner?: boolean;
|
||||
chapter?: PagePropsChapter;
|
||||
};
|
||||
|
||||
type PagePropsBook = {
|
||||
id: number;
|
||||
countryId: number;
|
||||
creatorId: number;
|
||||
lastChapterId: number;
|
||||
sourceId: number;
|
||||
imgVerticalId: number;
|
||||
imgHorizontalId: number;
|
||||
imgSquareId: number;
|
||||
imageVerticalId: number;
|
||||
imageHorizontalId: number;
|
||||
title: string;
|
||||
titleEn: string;
|
||||
fullTitle: string;
|
||||
fullTitleEn: string;
|
||||
description: string;
|
||||
slug: string;
|
||||
author: string;
|
||||
additionalInfo: string;
|
||||
importantInfo: string;
|
||||
status: string;
|
||||
views: number;
|
||||
likes: number;
|
||||
dislikes: number;
|
||||
intervalSec: number;
|
||||
intervalForNextFreeChapterSec: number;
|
||||
chapterCost: number;
|
||||
chapterPrice: number;
|
||||
chapterPriceCollected: number;
|
||||
maxSubscriptionChapters: number;
|
||||
maxDonateChapters: number;
|
||||
isDisplayOnMainPage: boolean;
|
||||
withoutSchedule: boolean;
|
||||
nextFreeChapterPublishedAt: null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
userRating: null;
|
||||
userBookmark: null;
|
||||
country: Country;
|
||||
genres: Genre[];
|
||||
timer: Timer;
|
||||
chapters: ChapterElement[];
|
||||
verticalImage: Image;
|
||||
horizontalImage: Image;
|
||||
squareImage: Image;
|
||||
};
|
||||
|
||||
type ChapterElement = {
|
||||
id: number;
|
||||
bookId: number;
|
||||
title: string;
|
||||
url: string;
|
||||
numberChapter: string;
|
||||
tom: number | null;
|
||||
chapterShortNumber: number | null;
|
||||
isDonate: boolean;
|
||||
isSponsored: boolean;
|
||||
isSubscription: boolean;
|
||||
isEdited: boolean;
|
||||
publishedAt: Date;
|
||||
price: number;
|
||||
views: number;
|
||||
isUserPaid?: boolean;
|
||||
};
|
||||
|
||||
type Country = {
|
||||
id: number;
|
||||
title: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
type Genre = {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
type Image = {
|
||||
id: number;
|
||||
alt: string;
|
||||
path: string;
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type Timer = {
|
||||
value: null;
|
||||
for: null;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type PagePropsChapter = {
|
||||
id: number;
|
||||
bookId: number;
|
||||
editorId: null;
|
||||
translatorId: number;
|
||||
parserChapterId: null;
|
||||
title: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
tom: null;
|
||||
views: number;
|
||||
isSponsored: boolean;
|
||||
isSubscription: boolean;
|
||||
isDonate: boolean;
|
||||
isIndexing: boolean;
|
||||
publishedAt: Date;
|
||||
changeAvailabilityAt: null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
numberChapter: string;
|
||||
price: number;
|
||||
content: Content;
|
||||
book: ChapterBook;
|
||||
userBookmark: null;
|
||||
isUserPaid: boolean;
|
||||
nextChapter: null;
|
||||
previousChapter: PreviousChapter;
|
||||
corrections: null;
|
||||
};
|
||||
|
||||
type ChapterBook = {
|
||||
id: number;
|
||||
title: string;
|
||||
titleEn: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type Content = {
|
||||
id: number;
|
||||
text: string;
|
||||
symbolsCount: number;
|
||||
};
|
||||
|
||||
type PreviousChapter = {
|
||||
id: number;
|
||||
title: string;
|
||||
views: number;
|
||||
publishedAt: Date;
|
||||
url: string;
|
||||
numberChapter: string;
|
||||
};
|
||||
|
||||
type InitialReduxState = {
|
||||
data?: Data;
|
||||
auth: Auth;
|
||||
banners?: Banners;
|
||||
corrections?: Corrections;
|
||||
readerSettings?: ReaderSettings;
|
||||
};
|
||||
|
||||
type Auth = {
|
||||
isAuth: boolean;
|
||||
user: null;
|
||||
};
|
||||
|
||||
type Banners = {
|
||||
displaySale: boolean;
|
||||
};
|
||||
|
||||
type Corrections = {
|
||||
items: null;
|
||||
data: null;
|
||||
};
|
||||
|
||||
type Data = {
|
||||
bookPage: BookPage | null;
|
||||
chapterPage: ChapterPage | null;
|
||||
};
|
||||
|
||||
type BookPage = {
|
||||
bookId: number;
|
||||
bookmark: null;
|
||||
};
|
||||
|
||||
type ChapterPage = {
|
||||
chapterId: number;
|
||||
bookId: number;
|
||||
bookmark: null;
|
||||
};
|
||||
|
||||
type ReaderSettings = {
|
||||
fontSize: number;
|
||||
lineHeight: number;
|
||||
textIndent: number;
|
||||
sidebarHide: number;
|
||||
textAlign: string;
|
||||
};
|
||||
|
||||
type TotalData = {
|
||||
items: Item[];
|
||||
_links: Links;
|
||||
pagesData: PagesData;
|
||||
};
|
||||
|
||||
type Links = {
|
||||
self: First;
|
||||
first: First;
|
||||
last: First;
|
||||
next: First;
|
||||
};
|
||||
|
||||
type First = {
|
||||
href: string;
|
||||
};
|
||||
|
||||
type Item = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
status: string;
|
||||
dislikes: number;
|
||||
likes: number;
|
||||
userRating: null;
|
||||
lastChapter: ChapterElement;
|
||||
verticalImage: Image;
|
||||
horizontalImage: Image;
|
||||
squareImage: Image;
|
||||
};
|
||||
|
||||
type PagesData = {
|
||||
totalCount: number;
|
||||
pageCount: number;
|
||||
currentPage: number;
|
||||
perPage: number;
|
||||
};
|
||||
|
||||
type Query = {
|
||||
book?: string;
|
||||
chapter?: string;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
||||
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 RV implements Plugin.PluginBase {
|
||||
id = 'RV';
|
||||
name = 'Ruvers';
|
||||
site = 'https://ruvers.ru/';
|
||||
version = '1.0.0';
|
||||
icon = 'src/ru/ruvers/icon.png';
|
||||
|
||||
async fetchNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
searchTerm?: string,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site + 'api/books?page=' + page;
|
||||
url += '&sort=' + (filters?.sort?.value || '-rating');
|
||||
|
||||
if (searchTerm) url += '&search=' + searchTerm;
|
||||
|
||||
const { data }: { data: book[] } = await fetchApi(url).then(res =>
|
||||
res.json(),
|
||||
);
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
data.forEach(novel =>
|
||||
novels.push({
|
||||
name: novel.name,
|
||||
cover: novel.images[0] ? this.site + novel.images[0] : defaultCover,
|
||||
path: novel.slug,
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
popularNovels = this.fetchNovels;
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const defaultOptions: any = { filters: {} };
|
||||
return this.fetchNovels(page, defaultOptions, searchTerm);
|
||||
}
|
||||
|
||||
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('div.name > h1').text().trim(),
|
||||
cover: loadedCheerio('.slider_prods_single > img').attr('src'),
|
||||
summary: loadedCheerio('.book_description').text().trim(),
|
||||
genres: loadedCheerio('.genres > a')
|
||||
.map((index, element) => loadedCheerio(element).text()?.trim())
|
||||
.get()
|
||||
.join(','),
|
||||
status: loadedCheerio('.status_row > div:nth-child(1) > a')
|
||||
.text()
|
||||
.includes('В работе')
|
||||
? NovelStatus.Ongoing
|
||||
: NovelStatus.Completed,
|
||||
};
|
||||
|
||||
const bookId = loadedCheerio('comments-list').attr('commentable-id');
|
||||
|
||||
const chaptersJSON: { data: chapters[] } = await fetchApi(
|
||||
this.site + 'api/books/' + bookId + '/chapters/all',
|
||||
).then(res => res.json());
|
||||
|
||||
if (chaptersJSON.data.length) {
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
chaptersJSON.data.forEach((chapter, chapterIndex) => {
|
||||
if (
|
||||
chapter.is_published &&
|
||||
(chapter.is_free || chapter.purchased_by_user)
|
||||
) {
|
||||
chapters.push({
|
||||
name: 'Глава ' + chapter.number + ' ' + (chapter.name || ''),
|
||||
path: novelPath + '/' + chapter.id,
|
||||
releaseTime: dayjs(chapter.created_at).format('LLL'),
|
||||
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 encrypted = body.match(
|
||||
/(mobile-books|books)-chapters-text-component.*:text='"(.*?)"'/s,
|
||||
)?.[2];
|
||||
if (!encrypted) throw new Error('No chapter found');
|
||||
|
||||
return unicodeToUtf8(encrypted);
|
||||
}
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Сортировка',
|
||||
value: '-rating',
|
||||
options: [
|
||||
{ label: 'По названию', value: 'name' },
|
||||
{ label: 'По дате добавления', value: '-created_at' },
|
||||
{ label: 'По рейтингу', value: '-rating' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new RV();
|
||||
|
||||
type book = {
|
||||
id: number;
|
||||
name: string;
|
||||
original_name: string;
|
||||
description: string;
|
||||
publishing_house?: string | null;
|
||||
country: string;
|
||||
original_author: string;
|
||||
images: string[];
|
||||
price?: number | null;
|
||||
is_verified: boolean;
|
||||
rating: number;
|
||||
type: string;
|
||||
release_year: number;
|
||||
tags?: string[] | null;
|
||||
isbn?: null;
|
||||
alternative_title?: string | null;
|
||||
count_chapters: number;
|
||||
chapter_open_days?: number | null;
|
||||
age_category_id: number;
|
||||
status_id: number;
|
||||
writer_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
slug: string;
|
||||
chapters_count: number;
|
||||
};
|
||||
|
||||
type chapters = {
|
||||
id: number;
|
||||
name: string;
|
||||
number: string;
|
||||
price?: string | null;
|
||||
open_by_timer: boolean;
|
||||
order: number;
|
||||
opened_at?: string | null;
|
||||
rating?: null;
|
||||
book_id: number;
|
||||
deleted_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
is_published: boolean;
|
||||
first_published_at: string;
|
||||
full_name: string;
|
||||
is_free: boolean;
|
||||
is_new: boolean;
|
||||
purchased_by_user: boolean;
|
||||
book_slug: string;
|
||||
};
|
||||
|
||||
function unicodeToUtf8(unicode: string) {
|
||||
return unicode.replace(/\\u([\d\w]{4})/gi, (match, hex) =>
|
||||
String.fromCharCode(parseInt(hex, 16)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
|
||||
class TopLiba implements Plugin.PluginBase {
|
||||
id = 'TopLiba';
|
||||
name = 'ТопЛиба';
|
||||
site = 'https://topliba.com';
|
||||
version = '1.0.0';
|
||||
icon = 'src/ru/topliba/icon.png';
|
||||
_token = '';
|
||||
|
||||
async fetchNovels(
|
||||
page: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
searchTerm?: string,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const data = new URLSearchParams({
|
||||
order_field: showLatestNovels ? 'date' : filters?.sort?.value || 'rating',
|
||||
p: page,
|
||||
});
|
||||
|
||||
if (searchTerm) data.append('q', searchTerm);
|
||||
const body = await fetchApi(this.site + '/?' + data.toString()).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
this._token = body.match(/<meta name="_token" content="(.*?)"/)?.[1];
|
||||
|
||||
const elements = body.match(/<img class="cover" data-original=".*>/g) || [];
|
||||
elements.forEach(element => {
|
||||
const [, path, name] =
|
||||
element.match(/data-original=".*covers\/(.*?)_.*title="(.*?)"/) || [];
|
||||
if (path && name) {
|
||||
novels.push({
|
||||
name,
|
||||
cover: this.site + '/covers/' + path + '.jpg',
|
||||
path,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
popularNovels = this.fetchNovels;
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const defaultOptions: any = {
|
||||
showLatestNovels: false,
|
||||
filters: {},
|
||||
};
|
||||
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('div > h1').text().trim(),
|
||||
cover: this.site + '/covers/' + novelPath + '.jpg',
|
||||
summary: loadedCheerio('.description').text().trim(),
|
||||
author: loadedCheerio('.book-author > a').text().trim(),
|
||||
genres: loadedCheerio('.book-genres > div > a')
|
||||
.map((index, element) => loadedCheerio(element).text())
|
||||
.get()
|
||||
.join(','),
|
||||
};
|
||||
|
||||
const chaptersHTML = await fetchApi(this.resolveUrl(novelPath)).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
|
||||
this._token =
|
||||
chaptersHTML.match(/<meta name="_token" content="(.*?)"/)?.[1] ||
|
||||
body.match(/<meta name="_token" content="(.*?)"/)?.[1] ||
|
||||
this._token;
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
const elements =
|
||||
chaptersHTML.match(
|
||||
/<li class="padding-\d+" data-capter="\d+">([\s\S]*?)</g,
|
||||
) || [];
|
||||
elements.forEach((chapter, chapterIndex) => {
|
||||
const [, padding, capter, name] =
|
||||
chapter.match(
|
||||
/class="padding-(\d+)" data-capter="(\d+)">([\s\S]*?)</,
|
||||
) || [];
|
||||
|
||||
if (padding && capter && name) {
|
||||
const id =
|
||||
padding === '0' ? capter : padding + '-' + (parseInt(capter, 10) - 1);
|
||||
|
||||
chapters.push({
|
||||
name: name.trim(),
|
||||
path: novelPath + '?' + id,
|
||||
releaseTime: null,
|
||||
chapterNumber: chapterIndex + 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const [bookID, chapterID] = chapterPath.split('?');
|
||||
if (!this._token) {
|
||||
const chaptersHTML = await fetchApi(this.resolveUrl(bookID)).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
this._token = chaptersHTML.match(
|
||||
/<meta name="_token" content="(.*?)"/,
|
||||
)?.[1];
|
||||
}
|
||||
|
||||
const chapterText = await fetchApi(this.resolveUrl(bookID) + '/chapter', {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
Referer: this.resolveUrl(bookID),
|
||||
Origin: this.site,
|
||||
},
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
chapter: chapterID,
|
||||
_token: this._token,
|
||||
}).toString(),
|
||||
}).then(res => res.text());
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
resolveUrl = (path: string, isNovel?: boolean) =>
|
||||
this.site + (isNovel ? '/books/' + path : '/reader/' + path.split('?')[0]);
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Сортировка:',
|
||||
value: 'rating',
|
||||
options: [
|
||||
{ label: 'По рейтингу', value: 'rating' },
|
||||
{ label: 'По популярности', value: 'num_downloads' },
|
||||
{ label: 'По году выхода', value: 'year' },
|
||||
{ label: 'По дате добавления', value: 'date' },
|
||||
{ label: 'По названию', value: 'title' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new TopLiba();
|
||||
@@ -0,0 +1,396 @@
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class Zelluloza implements Plugin.PluginBase {
|
||||
id = 'zelluloza';
|
||||
name = 'Целлюлоза';
|
||||
site = 'https://zelluloza.ru';
|
||||
version = '1.0.3';
|
||||
icon = 'src/ru/zelluloza/icon.png';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
filters,
|
||||
showLatestNovels,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const sort = showLatestNovels ? '0' : filters?.sort?.value || '3';
|
||||
|
||||
const genres = filters?.genres?.value || '0';
|
||||
|
||||
const body = await fetchApi(this.site + '/ajaxcall/', {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Referer: this.site + '/search/done/#result',
|
||||
Origin: this.site,
|
||||
},
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
op: 'morebooks',
|
||||
par1: '',
|
||||
par2: `206:0:${genres}:0.${sort}.0.0.0.0.0.0.0.0.0.0.0..0..:${pageNo}`,
|
||||
par4: '',
|
||||
}).toString(),
|
||||
}).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('div[style="display: flex;"]').each((index, element) => {
|
||||
novels.push({
|
||||
name: loadedCheerio(element).find('a[class="txt"]').attr('title') || '',
|
||||
cover:
|
||||
this.site +
|
||||
loadedCheerio(element).find('img[class="shadow"]').attr('src'),
|
||||
path: (
|
||||
loadedCheerio(element).find('a[class="txt"]').attr('href') || ''
|
||||
).replace(/\D/g, ''),
|
||||
});
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const body = await fetchApi(this.resolveUrl(novelPath)).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h2[class="bookname"]').text().trim(),
|
||||
cover: this.site + loadedCheerio('img[class="shadow"]').attr('src'),
|
||||
genres: loadedCheerio('.gnres span[itemprop="genre"]')
|
||||
.map((index, element) => loadedCheerio(element).text())
|
||||
.get()
|
||||
.join(','),
|
||||
summary:
|
||||
loadedCheerio('#bann_full').text() ||
|
||||
loadedCheerio('#bann_short').text(),
|
||||
author: loadedCheerio('.author_link').text(),
|
||||
status: loadedCheerio('.tech_decription').text().includes('Пишется')
|
||||
? NovelStatus.Ongoing
|
||||
: NovelStatus.Completed,
|
||||
};
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
loadedCheerio('ul[class="g0"] div[class="w800_m"]').each(
|
||||
(chapterIndex, element) => {
|
||||
const isFree = loadedCheerio(element).find(
|
||||
'div[class="chaptfree"]',
|
||||
).length;
|
||||
if (isFree) {
|
||||
const chapter = loadedCheerio(element).find('a[class="chptitle"]');
|
||||
const releaseDate = loadedCheerio(element)
|
||||
.find('div[class="stat"]')
|
||||
.text();
|
||||
chapters.push({
|
||||
name: chapter.text().trim(),
|
||||
path: (chapter.attr('href') || '').split('/').slice(2, 4).join('/'),
|
||||
releaseTime: this.parseDate(releaseDate),
|
||||
chapterNumber: chapterIndex + 1,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const [bookID, chapterID] = chapterPath.split('/');
|
||||
const body = await fetchApi(this.site + '/ajaxcall/', {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Referer: this.resolveUrl(chapterPath),
|
||||
Origin: this.site,
|
||||
},
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
op: 'getbook',
|
||||
par1: bookID,
|
||||
par2: chapterID,
|
||||
}).toString(),
|
||||
}).then(res => res.text());
|
||||
|
||||
const encrypted = body.split('<END>')[0].split('\n');
|
||||
const decrypted = encrypted
|
||||
.map(str => decrypt(str))
|
||||
.join('')
|
||||
.replace(/\r/g, '')
|
||||
.trim();
|
||||
|
||||
const chapterText = decrypted //cosmetic filters
|
||||
.replace(/\[\*]([\s\S]*?)\[\/]/g, '<b>$1</b>')
|
||||
.replace(/\[_]([\s\S]*?)\[\/]/g, '<u>$1</u>')
|
||||
.replace(/\[-]([\s\S]*?)\[\/]/g, '<s>$1</s>')
|
||||
.replace(/\[~]([\s\S]*?)\[\/]/g, '<i>$1</i>'); /*
|
||||
.replace(/\[!]([\s\S]*?)\[\/]/g, '<span style="font-family: Times New Roman">$1</span>')
|
||||
.replace(/\[ctr]([\s\S]*?)\[\/]/g, "<center>$1</center>")
|
||||
.replace(/\[rht]([\s\S]*?)\[\/]/g, '<span style="text-align: right">$1</span>')
|
||||
.replace(/\[grn]([\s\S]*?)\[\/]/g, '<span style="color: #019208">$1</span>')
|
||||
.replace(/\[red]([\s\S]*?)\[\/]/g, '<span style="color: #E84040">$1</span>')
|
||||
.replace(/\[blu]([\s\S]*?)\[\/]/g, '<span style="color: #354FA3">$1</span>')
|
||||
.replace(/\[gry]([\s\S]*?)\[\/]/g, '<span style="color: #909090">$1</span>')
|
||||
.replace(/\[geo]([\s\S]*?)\[\/]/g, '<span style="font-family: Georgia">$1</span>')
|
||||
.replace(/\[gld]([\s\S]*?)\[\/]/g, '<span style="color: #988c07">$1</span>');*/
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const body = await fetchApi(this.site + '/ajaxcall/', {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Referer: this.site + '/search/done/#result',
|
||||
Origin: this.site,
|
||||
},
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
op: 'morebooks',
|
||||
par1: searchTerm || '',
|
||||
par2: '206:0:0:0.0.0.0.0.0.0.10.0.0.0.0.0..0..:' + pageNo,
|
||||
par4: '',
|
||||
}).toString(),
|
||||
}).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('div[style="display: flex;"]').each((index, element) => {
|
||||
novels.push({
|
||||
name: loadedCheerio(element).find('a[class="txt"]').attr('title') || '',
|
||||
cover:
|
||||
this.site +
|
||||
loadedCheerio(element).find('img[class="shadow"]').attr('src'),
|
||||
path: (
|
||||
loadedCheerio(element).find('a[class="txt"]').attr('href') || ''
|
||||
).replace(/\D/g, ''),
|
||||
});
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
parseDate = (dateString: string | undefined = '') => {
|
||||
const [, day, month, year, hour, minute] =
|
||||
dateString.match(/(\d{2})\.(\d{2})\.(\d{4})г (\d{2}):(\d{2})/) || [];
|
||||
|
||||
if (day && month && year && hour && minute) {
|
||||
return dayjs(
|
||||
year + '-' + month + '-' + day + ' ' + hour + ':' + minute,
|
||||
).format('LLL');
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
resolveUrl = (path: string) => this.site + '/books/' + path;
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Сортировка',
|
||||
value: '3',
|
||||
options: [
|
||||
{ label: 'По рейтингу', value: '3' },
|
||||
{ label: 'По изменению', value: '0' },
|
||||
{ label: 'По длительности чтения', value: '1' },
|
||||
{ label: 'По количеству читателей', value: '2' },
|
||||
{ label: 'По популярности', value: '4' },
|
||||
{ label: 'Самые продаваемые', value: '5' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genres: {
|
||||
label: 'Жанры',
|
||||
value: '',
|
||||
options: [
|
||||
{ label: 'Все', value: '' },
|
||||
{ label: '16+', value: '204' },
|
||||
{ label: '18+', value: '100' },
|
||||
{ label: 'Авантюра', value: '347' },
|
||||
{ label: 'Альтернативная история', value: '55' },
|
||||
{ label: 'Ангст', value: '463' },
|
||||
{ label: 'Аниме', value: '405' },
|
||||
{ label: 'Антиутопия', value: '21' },
|
||||
{ label: 'Апокалипсис', value: '368' },
|
||||
{ label: 'Аудиокнига', value: '391' },
|
||||
{ label: 'Байки', value: '435' },
|
||||
{ label: 'Бизнес-книги', value: '522' },
|
||||
{ label: 'Биография', value: '52' },
|
||||
{ label: 'Биопанк', value: '379' },
|
||||
{ label: 'Боевая фантастика', value: '512' },
|
||||
{ label: 'Боевик', value: '61' },
|
||||
{ label: 'Боевые искусства', value: '142' },
|
||||
{ label: 'Бояръ-аниме', value: '433' },
|
||||
{ label: 'Вбоквелл', value: '372' },
|
||||
{ label: 'Вестерн', value: '96' },
|
||||
{ label: 'Война миров', value: '137' },
|
||||
{ label: 'Восточное фэнтези', value: '505' },
|
||||
{ label: 'Городское фентези', value: '63' },
|
||||
{ label: 'Городское фэнтези', value: '520' },
|
||||
{ label: 'Готика', value: '83' },
|
||||
{ label: 'Детективы', value: '4' },
|
||||
{ label: 'Детские книги', value: '8' },
|
||||
{ label: 'Документальные книги', value: '351' },
|
||||
{ label: 'Дом и дача', value: '534' },
|
||||
{ label: 'Дорожные истории', value: '41' },
|
||||
{ label: 'Драма', value: '47' },
|
||||
{ label: 'Древняя литература', value: '529' },
|
||||
{ label: 'Женский психологический роман', value: '186' },
|
||||
{ label: 'Женский роман', value: '141' },
|
||||
{ label: 'Зарубежная классика', value: '524' },
|
||||
{ label: 'Здоровье и красота', value: '533' },
|
||||
{ label: 'Изучение языков', value: '537' },
|
||||
{ label: 'Иронический детектив', value: '85' },
|
||||
{ label: 'Исследования', value: '156' },
|
||||
{ label: 'Историческая литература', value: '3' },
|
||||
{ label: 'Исторический любовный роман', value: '518' },
|
||||
{ label: 'Историческое фэнтези', value: '90' },
|
||||
{ label: 'Киберпанк', value: '7' },
|
||||
{ label: 'Классическая литература', value: '9' },
|
||||
{ label: 'Книга-игра', value: '371' },
|
||||
{ label: 'Книги для родителей', value: '535' },
|
||||
{ label: 'Комедия', value: '80' },
|
||||
{ label: 'Конкурсные сборники', value: '349' },
|
||||
{ label: 'Короткий любовный роман', value: '517' },
|
||||
{ label: 'Космическая фантастика', value: '513' },
|
||||
{ label: 'Космоопера', value: '58' },
|
||||
{ label: 'Криминальная проза', value: '35' },
|
||||
{ label: 'Криптоистория', value: '400' },
|
||||
{ label: 'Ксенофантастика', value: '102' },
|
||||
{ label: 'Культура и искусство', value: '536' },
|
||||
{ label: 'Легенды', value: '161' },
|
||||
{ label: 'Лирика', value: '49' },
|
||||
{ label: 'ЛитРПГ', value: '62' },
|
||||
{ label: 'Любовная фантастика', value: '516' },
|
||||
{ label: 'Любовное фэнтези', value: '515' },
|
||||
{ label: 'Любовный роман', value: '46' },
|
||||
{ label: 'Магический реализм', value: '42' },
|
||||
{ label: 'Матриархат', value: '385' },
|
||||
{ label: 'Медицина', value: '57' },
|
||||
{ label: 'Мелодрама', value: '45' },
|
||||
{ label: 'Мемуары', value: '532' },
|
||||
{ label: 'Метафизика', value: '430' },
|
||||
{ label: 'Милитари', value: '126' },
|
||||
{ label: 'Мистика', value: '22' },
|
||||
{ label: 'Морские приключения', value: '211' },
|
||||
{ label: 'Мотивация', value: '135' },
|
||||
{ label: 'Научная фантастика', value: '122' },
|
||||
{ label: 'Научно-популярная', value: '43' },
|
||||
{ label: 'Ненаучная фантастика', value: '387' },
|
||||
{ label: 'Новелла', value: '498' },
|
||||
{ label: 'Нон-фикшн', value: '507' },
|
||||
{ label: 'Нуар', value: '121' },
|
||||
{ label: 'О войне', value: '136' },
|
||||
{ label: 'Оккультизм', value: '424' },
|
||||
{ label: 'Параллельные миры', value: '438' },
|
||||
{ label: 'Пародия', value: '108' },
|
||||
{ label: 'Подростковая фантастика', value: '27' },
|
||||
{ label: 'Подростковый роман', value: '388' },
|
||||
{ label: 'Подростковый ужастик', value: '110' },
|
||||
{ label: 'Постапокалипсис', value: '82' },
|
||||
{ label: 'Постапокалиптика', value: '67' },
|
||||
{ label: 'Поэзия', value: '44' },
|
||||
{ label: 'Превращение', value: '170' },
|
||||
{ label: 'Приключение', value: '497' },
|
||||
{ label: 'Приключения', value: '5' },
|
||||
{ label: 'Природа и животные', value: '538' },
|
||||
{ label: 'Проза', value: '95' },
|
||||
{ label: 'Производственный роман', value: '148' },
|
||||
{ label: 'Психология', value: '31' },
|
||||
{ label: 'Путешествия', value: '383' },
|
||||
{ label: 'Рассказы', value: '101' },
|
||||
{ label: 'Реализм', value: '422' },
|
||||
{ label: 'РеалРПГ', value: '442' },
|
||||
{ label: 'Религия', value: '188' },
|
||||
{ label: 'Робинзонада', value: '434' },
|
||||
{ label: 'Романтика', value: '130' },
|
||||
{ label: 'Романтическая фантастика', value: '501' },
|
||||
{ label: 'Русская классика', value: '523' },
|
||||
{ label: 'Рыцарский роман', value: '112' },
|
||||
{ label: 'Символизм', value: '439' },
|
||||
{ label: 'Сказки', value: '93' },
|
||||
{ label: 'Славянское фэнтези', value: '508' },
|
||||
{ label: 'Сновидения', value: '428' },
|
||||
{ label: 'Современный любовный роман', value: '519' },
|
||||
{ label: 'Социально-психологическая фантастика', value: '98' },
|
||||
{ label: 'Спорт', value: '147' },
|
||||
{ label: 'Справочники', value: '528' },
|
||||
{ label: 'Стимпанк', value: '56' },
|
||||
{ label: 'Субкультура', value: '209' },
|
||||
{ label: 'Сюрреализм', value: '26' },
|
||||
{ label: 'Тёмное фэнтези', value: '437' },
|
||||
{ label: 'Техномагия', value: '159' },
|
||||
{ label: 'Триллер', value: '6' },
|
||||
{ label: 'Трэш', value: '162' },
|
||||
{ label: 'Ужасы', value: '531' },
|
||||
{ label: 'Утопия', value: '191' },
|
||||
{ label: 'Учебник', value: '411' },
|
||||
{ label: 'Фантастика', value: '1' },
|
||||
{ label: 'Фантастический боевик', value: '38' },
|
||||
{ label: 'Фанфик', value: '111' },
|
||||
{ label: 'Философия', value: '461' },
|
||||
{ label: 'Фурри', value: '394' },
|
||||
{ label: 'Фэнтези', value: '2' },
|
||||
{ label: 'Хентай', value: '441' },
|
||||
{ label: 'Хобби и досуг', value: '530' },
|
||||
{ label: 'Хоррор', value: '86' },
|
||||
{ label: 'Чёрный юмор', value: '73' },
|
||||
{ label: 'Эзотерика', value: '30' },
|
||||
{ label: 'Экшн', value: '94' },
|
||||
{ label: 'Эпическое фэнтези', value: '499' },
|
||||
{ label: 'Эротика', value: '19' },
|
||||
{ label: 'Эссе', value: '353' },
|
||||
{ label: 'ЭТТИ', value: '502' },
|
||||
{ label: 'Юмористическая литература', value: '20' },
|
||||
{ label: 'Юмористическая фантастика', value: '514' },
|
||||
{ label: 'Юмористическое фэнтези', value: '521' },
|
||||
{ label: 'EVE - Миры Содружества', value: '99' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
const alphabet: Record<string, string> = {
|
||||
'~': '0',
|
||||
'H': '1',
|
||||
'^': '2',
|
||||
'@': '3',
|
||||
'f': '4',
|
||||
'0': '5',
|
||||
'5': '6',
|
||||
'n': '7',
|
||||
'r': '8',
|
||||
'=': '9',
|
||||
'W': 'a',
|
||||
'L': 'b',
|
||||
'7': 'c',
|
||||
' ': 'd',
|
||||
'u': 'e',
|
||||
'c': 'f',
|
||||
};
|
||||
|
||||
function decrypt(encrypt: string) {
|
||||
if (!encrypt) return '';
|
||||
const hexArray = [];
|
||||
|
||||
for (let j = 0; j < encrypt.length; j += 2) {
|
||||
const firstChar = encrypt.substring(j, j + 1);
|
||||
const secondChar = encrypt.substring(j + 1, j + 2);
|
||||
hexArray.push(alphabet[firstChar] + alphabet[secondChar]);
|
||||
}
|
||||
|
||||
return '<p>' + decodeURIComponent('%' + hexArray.join('%')) + '</p>';
|
||||
}
|
||||
|
||||
export default new Zelluloza();
|
||||
Reference in New Issue
Block a user