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
+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;
};