Feat: Plugin storages (#904)

* update

* update

* Update ranobelib.ts

* test

* test
This commit is contained in:
Rider21
2024-05-28 11:53:56 +03:00
committed by GitHub
parent 020930ed0a
commit df0c4b0a81
8 changed files with 708 additions and 203 deletions
+116 -44
View File
@@ -4,23 +4,22 @@ import { defaultCover } from '@libs/defaultCover';
import { fetchApi, fetchFile } from '@libs/fetch';
import { NovelStatus } from '@libs/novelStatus';
import { load as parseHTML } from 'cheerio';
import { storage } from '@libs/storage';
import dayjs from 'dayjs';
const apiUrl = 'https://api.author.today/';
const token = 'Bearer guest';
class AuthorToday implements Plugin.PluginBase {
id = 'AT';
name = 'Автор Тудей';
icon = 'src/ru/authortoday/icon.png';
site = 'https://author.today';
version = '1.0.0';
apiUrl = 'https://api.author.today/v1/';
version = '1.1.0';
async popularNovels(
pageNo: number,
{ showLatestNovels, filters }: Plugin.PopularNovelsOptions,
): Promise<Plugin.NovelItem[]> {
let url = apiUrl + 'v1/catalog/search?page=' + pageNo;
let url = this.apiUrl + 'catalog/search?page=' + pageNo;
if (filters?.genre?.value) {
url += '&genre=' + filters.genre.value;
}
@@ -35,19 +34,18 @@ class AuthorToday implements Plugin.PluginBase {
url += '&access=' + (filters?.access?.value || 'any');
url += '&promo=' + (filters?.promo?.value || 'hide');
const result = await fetchApi(url, {
const result: response = await fetchApi(url, {
headers: {
Authorization: token,
Authorization: 'Bearer guest',
},
});
const json = (await result.json()) as response;
}).then(res => res.json());
const novels: Plugin.NovelItem[] = [];
if (json.code === 'NotFound') {
if (result.code === 'NotFound') {
return novels;
}
json?.searchResults?.forEach(novel =>
result?.searchResults?.forEach(novel =>
novels.push({
name: novel.title,
cover: novel.coverUrl
@@ -61,13 +59,16 @@ class AuthorToday implements Plugin.PluginBase {
}
async parseNovel(workID: string): Promise<Plugin.SourceNovel> {
const result = await fetchApi(`${apiUrl}v1/work/${workID}/details`, {
headers: {
Authorization: token,
if (!this.user) this.user = await this.getUser();
const book: responseBook = await fetchApi(
this.apiUrl + 'work/' + workID + '/details',
{
headers: {
Authorization: 'Bearer ' + this.user?.token || 'guest',
},
},
});
).then(res => res.json());
const book = (await result.json()) as responseBook;
const novel: Plugin.SourceNovel = {
path: workID,
name: book.title,
@@ -91,15 +92,16 @@ class AuthorToday implements Plugin.PluginBase {
novel.summary += 'Примечания автора:\n' + book.authorNotes;
}
const chaptersRaw = await fetchApi(`${apiUrl}v1/work/${workID}/content`, {
headers: {
Authorization: token,
const chaptersJSON: ChaptersEntity[] = await fetchApi(
this.apiUrl + 'work/' + workID + '/content',
{
headers: {
Authorization: 'Bearer ' + this.user?.token || 'guest',
},
},
});
).then(res => res.json());
const chaptersJSON = (await chaptersRaw.json()) as ChaptersEntity[];
const chapters: Plugin.ChapterItem[] = [];
chaptersJSON.forEach((chapter, chapterIndex) => {
if (chapter.isAvailable && !chapter.isDraft) {
chapters.push({
@@ -119,37 +121,30 @@ class AuthorToday implements Plugin.PluginBase {
async parseChapter(chapterPath: string): Promise<string> {
const [workID, chapterID] = chapterPath.split('/');
const result = await fetchApi(
apiUrl + `v1/work/${workID}/chapter/${chapterID}/text`,
if (!this.user) this.user = await this.getUser();
const result: encryptedСhapter = await fetchApi(
this.apiUrl + `work/${workID}/chapter/${chapterID}/text`,
{
headers: {
Authorization: token,
Authorization: 'Bearer ' + this.user?.token || 'guest',
},
},
);
const json = (await result.json()) as encryptedСhapter;
).then(res => res.json());
if (json.code) {
return json.code + '\n' + json?.message;
if (result.code) {
return result.code + '\n' + result?.message;
}
const key = json.key.split('').reverse().join('') + '@_@';
let text = '';
for (let i = 0; i < json.text.length; i++) {
text += String.fromCharCode(
json.text.charCodeAt(i) ^ key.charCodeAt(Math.floor(i % key.length)),
);
const chapterText = decrypt(result.text, result.key, this.user?.userId);
if (chapterText.includes('<img')) {
return chapterText.replace(/src="(.*?)"/g, (match, url) => {
if (!url.startsWith('http')) {
return `src="${this.site}${url}"`;
}
return `src="${url}"`;
});
}
const loadedCheerio = parseHTML(text);
loadedCheerio('img').each((index, element) => {
if (!loadedCheerio(element).attr('src')?.startsWith('http')) {
const src = loadedCheerio(element).attr('src');
loadedCheerio(element).attr('src', this.site + src);
}
});
const chapterText = loadedCheerio.html();
return chapterText;
}
@@ -184,6 +179,40 @@ class AuthorToday implements Plugin.PluginBase {
resolveUrl = (path: string, isNovel?: boolean) =>
isNovel ? this.site + '/work/' + path : this.site + '/reader/' + path;
user: authorization | undefined;
getUser = async () => {
let user = storage.get('user') || { userId: '', token: 'guest' };
if (user && user.userId && user.token) {
const currentUser: currentUser = await fetchApi(
this.apiUrl + 'account/current-user',
{
headers: {
Authorization: 'Bearer ' + user.token || 'guest',
},
},
).then(res => res.json());
if (currentUser?.id && !currentUser.isDisabled) return user;
storage.delete('user');
user = { userId: '', token: 'guest' };
}
const result = await fetchApi(this.site + '/account/bearer-token');
if (result.url.includes('Login?ReturnUrl=')) {
return user; //It looks like the user has lost the session
}
const loginUser: authorization = await result.json();
user = { userId: loginUser.userId, token: loginUser.token };
storage.set(
'user',
user, //for some reason they're ending an hour early.
new Date(loginUser.expires).getTime() - 1 * 60 * 60 * 1000,
);
return user; //user authorized successfully
};
filters = {
sort: {
label: 'Сортировка',
@@ -333,6 +362,48 @@ class AuthorToday implements Plugin.PluginBase {
export default new AuthorToday();
function decrypt(
encrypt: string,
encryptedKey: string,
userId: number | string = '',
) {
const key = encryptedKey.split('').reverse().join('') + '@_@' + userId;
let text = '';
for (let i = 0; i < encrypt.length; i++) {
text += String.fromCharCode(
encrypt.charCodeAt(i) ^ key.charCodeAt(i % key.length),
);
}
return text;
}
interface authorization {
userId: number;
token: string;
issued: string;
expires: string;
}
interface currentUser {
id: number;
userName: string;
fio: string;
email: string;
avatarUrl?: null;
backgroundUrl?: null;
status?: null;
hideDislike: boolean;
hideFinished: boolean;
isBanned: boolean;
banReason?: null;
banEnd?: null;
emailConfirmed: boolean;
isDeleted: boolean;
isDisabled: boolean;
}
interface response {
searchResults?: SearchResultsEntity[] | null;
realTotalCount: number;
@@ -400,6 +471,7 @@ interface Duration {
}
interface responseBook {
message?: string;
chapters?: ChaptersEntity[] | null;
allowDownloads: boolean;
downloadErrorCode: string;
+388 -122
View File
@@ -1,16 +1,26 @@
import { Plugin } from '@typings/plugin';
import { FilterTypes, Filters } from '@libs/filterInputs';
import { defaultCover } from '@libs/defaultCover';
import { fetchApi, fetchFile } from '@libs/fetch';
import { NovelStatus } from '@libs/novelStatus';
import { load as parseHTML } from 'cheerio';
import { storage, localStorage } from '@libs/storage';
import dayjs from 'dayjs';
const statusKey: { [key: number]: string } = {
1: NovelStatus.Ongoing,
2: NovelStatus.Completed,
3: NovelStatus.OnHiatus,
4: NovelStatus.Cancelled,
};
class RLIB implements Plugin.PluginBase {
id = 'RLIB';
name = 'RanobeLib (OLD)';
site = 'https://old.ranobelib.me/old/';
version = '1.1.0';
name = 'RanobeLib';
site = 'https://ranobelib.me';
apiSite = 'https://api.lib.social/api/manga/';
version = '2.0.0';
icon = 'src/ru/ranobelib/icon.png';
ui: string | undefined = undefined;
webStorageUtilized = true;
async popularNovels(
pageNo: number,
@@ -19,7 +29,7 @@ class RLIB implements Plugin.PluginBase {
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
let url = this.site + 'manga-list?page=' + pageNo;
let url = this.apiSite + '?site_id[0]=3&page=' + pageNo;
url +=
'&sort_by=' +
(showLatestNovels
@@ -65,138 +75,196 @@ class RLIB implements Plugin.PluginBase {
}
}
const result = await fetchApi(url).then(res => res.text());
const loadedCheerio = parseHTML(result);
const result: TopLevel = await fetchApi(url, {
headers: this.user?.token,
}).then(res => res.json());
const novels: Plugin.NovelItem[] = [];
this.ui = loadedCheerio('a.header-right-menu__item')
.attr('href')
?.replace?.(/[^0-9]/g, '');
const novelsRaw = result.match(/window\.__CATALOG_ITEMS__ = (\[.*?\]);/);
if (novelsRaw instanceof Array && novelsRaw.length >= 2) {
const novelsJson: resNovels[] = JSON.parse(novelsRaw[1]);
novelsJson.forEach(novel => {
if (result.data instanceof Array) {
result.data.forEach(novel =>
novels.push({
name: novel.rus_name,
cover: novel.cover.default,
path: novel.slug_url,
});
});
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 body = await fetchApi(this.resolveUrl(novelPath, true)).then(res =>
res.text(),
);
const loadedCheerio = parseHTML(body);
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 },
).then(res => res.json());
const novel: Plugin.SourceNovel = {
path: novelPath,
name: loadedCheerio('.media-name__main').text()?.trim?.() || '',
name: data.rus_name || data.name,
cover: data.cover?.default || defaultCover,
summary: data.summary,
};
novel.cover = loadedCheerio('.container_responsive img').attr('src');
novel.summary = loadedCheerio('.media-description__text').text().trim();
if (data.status?.id) {
novel.status = statusKey[data.status.id] || NovelStatus.Unknown;
}
novel.genres = loadedCheerio('div[class="media-tags"]')
.text()
.trim()
.replace(/[\n\r]+/g, ', ')
.replace(/ /g, '');
if (data.authors?.length) {
novel.author = data.authors[0].name;
}
if (data.artists?.length) {
novel.artist = data.artists[0].name;
}
loadedCheerio(
'div.media-info-list > div[class="media-info-list__item"]',
).each(function () {
let name = loadedCheerio(this)
.find('div[class="media-info-list__title"]')
.text();
const genres = [data.genres || [], data.tags || []]
.flat()
.map(genres => genres?.name)
.filter(genres => genres);
if (genres.length) {
novel.genres = genres.join(', ');
}
if (name === 'Автор') {
novel.author = loadedCheerio(this)
.find('div[class="media-info-list__value"]')
.text()
.trim();
} else if (name === 'Художник') {
novel.artist = loadedCheerio(this)
.find('div[class="media-info-list__value"]')
.text()
.trim();
}
});
const branch_id: { [key: number]: string } = {};
if (data.teams.length) {
data.teams.forEach(
({ name, details }) => (branch_id[details?.branch_id || '0'] = name),
);
}
this.ui = loadedCheerio('a.header-right-menu__item')
.attr('href')
?.replace?.(/[^0-9]/g, '');
const chaptersRaw = body.match(/window\.__CHAPTERS__ = (\[.*?\]);/);
if (chaptersRaw instanceof Array && chaptersRaw.length >= 2) {
const chaptersJson: resChapters[] = JSON.parse(chaptersRaw[1]);
if (!chaptersJson?.length) return novel;
const chaptersJSON: { data: DataChapter[] } = await fetchApi(
this.apiSite + novelPath + '/chapters',
{
headers: this.user?.token,
},
).then(res => res.json());
if (chaptersJSON.data.length) {
const chapters: Plugin.ChapterItem[] = [];
chaptersJson.forEach(chapter =>
chaptersJSON.data.forEach(chapter =>
chapters.push({
name:
'Том ' +
chapter.volume +
' Глава ' +
chapter.number +
(chapter.name ? ' ' + chapter.name.trim() : ''),
path: novelPath + '/v' + chapter.volume + '/c' + chapter.number,
chapterNumber: chapter.index + 1,
(chapter.name ? ' ' + chapter.name : ''),
path:
novelPath +
'/' +
chapter.volume +
'/' +
chapter.number +
'/' +
(chapter.branches[0]?.branch_id || ''),
releaseTime: dayjs(chapter.branches[0].created_at).format('LLL'),
chapterNumber: chapter.index,
page: branch_id[chapter.branches[0].branch_id || '0'],
}),
);
novel.chapters = chapters;
}
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const result = await fetchApi(this.resolveUrl(chapterPath)).then(res =>
res.text(),
);
const [slug, volume, number, branch_id] = chapterPath.split('/');
let chapterText = '';
const loadedCheerio = parseHTML(result);
loadedCheerio('.reader-container img').each((index, element) => {
const src =
loadedCheerio(element).attr('data-src') ||
loadedCheerio(element).attr('src');
if (!src?.startsWith('http')) {
loadedCheerio(element).attr('src', this.site + src);
} else {
loadedCheerio(element).attr('src', src);
}
loadedCheerio(element).removeAttr('data-src');
});
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?.content;
}
const chapterText = loadedCheerio('.reader-container').html();
return chapterText || '';
return chapterText;
}
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
const result = await fetchApi(this.site + 'api/manga?q=' + searchTerm);
const { data }: { data: resNovels[] } = await result.json();
const novels: 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());
data.forEach(novel =>
novels.push({
name: novel.rus_name || novel.name,
cover: novel?.cover?.default || '',
path: novel.slug_url,
}),
);
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;
}
fetchImage = fetchFile;
resolveUrl = (path: string, isNovel?: boolean) =>
this.site + path + (this.ui ? '?ui=' + this.ui : '');
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: {
@@ -410,41 +478,230 @@ class RLIB implements Plugin.PluginBase {
export default new RLIB();
interface resNovels {
function jsonToHtml(json: HTML[], html: string = '') {
json.forEach(element => {
switch (element.type) {
case 'hardBreak':
html += '<br>';
break;
case 'horizontalRule':
html += '<hr>';
break;
case 'image':
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) : '<br>') +
'</p>';
break;
case 'text':
html += element.text;
break;
default:
html += JSON.stringify(element, null, '\t'); //maybe I missed something.
break;
}
});
return html;
}
interface HTML {
type: string;
content?: HTML[];
attrs?: Attrs;
text?: string;
}
interface Attrs {
src: string;
alt: string | null;
title: string | null;
}
interface authorization {
token: Token;
auth: Auth;
timestamp: number;
}
interface Token {
token_type: string;
expires_in: number;
access_token: string;
refresh_token: string;
timestamp: number;
}
interface Auth {
id: number;
name: string;
rus_name: string;
eng_name: string;
slug: string;
slug_url: string;
cover: Cover;
ageRestriction: AgeRestrictionOrTypeOrStatus;
site: number;
type: AgeRestrictionOrTypeOrStatus;
rating: Rating;
is_licensed: boolean;
model: string;
status: AgeRestrictionOrTypeOrStatus;
releaseDateString: string;
username: string;
avatar: Cover;
last_online_at: string;
metadata: Metadata;
}
interface Cover {
filename: string;
thumbnail: string;
default: string;
interface Metadata {
auth_domains: string;
}
interface AgeRestrictionOrTypeOrStatus {
interface TopLevel {
data: DataClass | DataClass[];
links?: Links;
meta?: Meta;
}
interface AgeRestriction {
id: number;
label: string;
}
interface Rating {
average: string;
averageFormated: string;
votes: number;
votesFormated: string;
user: number;
interface Branch {
id: number;
branch_id: null;
created_at: string;
teams: BranchTeam[];
user: User;
}
interface resChapters {
interface BranchTeam {
id: number;
slug: string;
slug_url: string;
model: string;
name: string;
cover: Cover;
}
interface Cover {
filename: null | string;
thumbnail: string;
default: string;
}
interface User {
username: string;
id: number;
}
interface Rating {
average: string;
votes: number;
votesFormated: string;
}
interface 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[];
}
interface 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;
}
interface Subscription {
is_subscribed: boolean;
source_type: string;
source_id: number;
relation: null;
}
interface Attachment {
id: null;
filename: string;
name: string;
extension: string;
url: string;
width: number;
height: number;
}
interface Genre {
id: number;
name: string;
}
interface DataTeam {
id: number;
slug: string;
slug_url: string;
model: string;
name: string;
cover: Cover;
details?: Details;
vk?: string;
discord?: null;
}
interface Details {
branch_id: null;
is_active: boolean;
subscriptions_count: null;
}
interface Links {
first: string;
last: null;
prev: null;
next: string;
}
interface Meta {
current_page?: number;
from?: number;
path?: string;
per_page?: number;
to?: number;
page?: number;
has_next_page?: boolean;
seed?: string;
country?: string;
}
interface DataChapter {
id: number;
index: number;
item_number: number;
@@ -452,4 +709,13 @@ interface resChapters {
number: string;
number_secondary: string;
name: string;
branches_count: number;
branches: Branch[];
}
interface BranchesEntity {
id: number;
branch_id?: number;
created_at: string;
teams?: BranchTeam[];
user: User;
}