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:
Rajarshee Chatterjee
2025-10-20 22:54:08 +05:30
committed by GitHub
parent 252beff0bf
commit 20c2b75b01
363 changed files with 6848 additions and 3007 deletions
+310
View File
@@ -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;
};
+454
View File
@@ -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;
};
+299
View File
@@ -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
+309
View File
@@ -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();
+456
View File
@@ -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;
};
+481
View File
@@ -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;
};
+936
View File
@@ -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
+783
View File
@@ -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[];
};
+405
View File
@@ -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
+182
View File
@@ -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)),
);
}
+165
View File
@@ -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();
+396
View File
@@ -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();