diff --git a/icons/src/ru/ranobelib/icon.png b/icons/src/ru/ranobelib/icon.png index 158c3800f..2bce5a1a3 100644 Binary files a/icons/src/ru/ranobelib/icon.png and b/icons/src/ru/ranobelib/icon.png differ diff --git a/libs/storage.ts b/libs/storage.ts new file mode 100644 index 000000000..9cd73bcbc --- /dev/null +++ b/libs/storage.ts @@ -0,0 +1,108 @@ +import fs from 'fs'; +import path from 'path'; + +class Storage { + private db: Record; + + /** + * Initializes a new instance of the Storage class. + */ + constructor() { + this.db = {}; + } + + /** + * Sets a key-value pair in storage. + * + * @param {string} key - The key to set. + * @param {any} value - The value to set. + * @param {Date | number} [expires] - Optional expiry date or time in milliseconds. + */ + set(key: string, value: any, expires?: Date | number): void { + this.db[key] = { + created: new Date(), + value, + expires: expires instanceof Date ? expires.getTime() : expires, + }; + } + + /** + * Retrieves the value for a given key from storage. + * + * @param {string} key - The key to retrieve the value for. + * @param {boolean} [raw] - Optional flag to return the raw stored item. + * @returns {any} The stored value or undefined if key is not found. + */ + get(key: string, raw?: boolean): any { + const item = this.db[key]; + if (item?.expires && Date.now() > item.expires) { + this.delete(key); + return undefined; + } + return raw ? item : item?.value; + } + + /** + * Retrieves all keys set by the `set` method. + * + * @returns {string[]} An array of keys. + */ + getAllKeys(): string[] { + return Object.keys(this.db); + } + + /** + * Deletes a key from the storage. + * + * @param pluginID - The ID of the plugin. + * @param key - The key to delete. + */ + delete(key: string): void { + delete this.db[key]; + } + + /** + * Clears all stored items from storage. + */ + clearAll(pluginID: string): void { + this.db = {}; + } +} + +// Export a singleton instance of the Storage class +export const storage = new Storage(); + +/* +These parameters cannot be implemented in `test-web`. +They are generated in the browser when js-scripts are executed +Read more + +https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage +https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage +*/ + +/** + * Represents the structure of a storage object with string keys and values. + */ +interface StorageObject { + [key: string]: any; +} + +/** + * Represents a simplified version of the browser's localStorage. + */ +class LocalStorage { + private db: StorageObject; + + constructor() { + this.db = {}; + } + + get(): StorageObject | undefined { + return this.db; + } +} + +// Export singleton instances of LocalStorage and sessionStorage +export const localStorage = new LocalStorage(); +export const sessionStorage = new LocalStorage(); diff --git a/package-lock.json b/package-lock.json index 3fe437a39..555cc1c0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,12 +16,13 @@ "dayjs": "^1.11.10", "express": "^4.18.2", "htmlparser2": "^9.1.0", + "image-size": "^1.1.1", "less": "^4.2.0", "module-alias": "^2.2.3", "protobufjs": "^7.2.6", "qs": "^6.11.2", "sanitize-html": "^2.12.1", - "terser": "^5.30.4", + "terser": "^5.31.0", "ts-node": "^10.9.2", "tsc-alias": "^1.8.8", "typescript": "^5.3.3", @@ -2229,15 +2230,17 @@ } }, "node_modules/image-size": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", - "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", - "optional": true, + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.1.1.tgz", + "integrity": "sha512-541xKlUw6jr/6gGuk92F+mYM5zaFAc5ahphvkqvNe2bQ6gVBkd6bfrmVJ2t4KDAfikAYZyIqTnktX3i6/aQDrQ==", + "dependencies": { + "queue": "6.0.2" + }, "bin": { "image-size": "bin/image-size.js" }, "engines": { - "node": ">=0.10.0" + "node": ">=16.x" } }, "node_modules/inflight": { @@ -2508,6 +2511,18 @@ "source-map": "~0.6.0" } }, + "node_modules/less/node_modules/image-size": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", + "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", + "optional": true, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/less/node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -3434,6 +3449,14 @@ "node": ">=0.4.x" } }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "dependencies": { + "inherits": "~2.0.3" + } + }, "node_modules/queue-lit": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.0.tgz", @@ -4098,9 +4121,9 @@ } }, "node_modules/terser": { - "version": "5.30.4", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.30.4.tgz", - "integrity": "sha512-xRdd0v64a8mFK9bnsKVdoNP9GQIKUAaJPTaqEQDL4w/J8WaW4sWXXoMZ+6SimPkfT5bElreXf8m9HnmPc3E1BQ==", + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.0.tgz", + "integrity": "sha512-Q1JFAoUKE5IMfI4Z/lkE/E6+SwgzO+x4tq4v1AyBLRj8VSYvRO6A/rQrPg1yud4g0En9EKI1TvFRF2tQFcoUkg==", "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -6290,10 +6313,12 @@ "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==" }, "image-size": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", - "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", - "optional": true + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.1.1.tgz", + "integrity": "sha512-541xKlUw6jr/6gGuk92F+mYM5zaFAc5ahphvkqvNe2bQ6gVBkd6bfrmVJ2t4KDAfikAYZyIqTnktX3i6/aQDrQ==", + "requires": { + "queue": "6.0.2" + } }, "inflight": { "version": "1.0.6", @@ -6486,6 +6511,12 @@ "tslib": "^2.3.0" }, "dependencies": { + "image-size": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", + "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", + "optional": true + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -7168,6 +7199,14 @@ "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", "integrity": "sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==" }, + "queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "requires": { + "inherits": "~2.0.3" + } + }, "queue-lit": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.0.tgz", @@ -7669,9 +7708,9 @@ } }, "terser": { - "version": "5.30.4", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.30.4.tgz", - "integrity": "sha512-xRdd0v64a8mFK9bnsKVdoNP9GQIKUAaJPTaqEQDL4w/J8WaW4sWXXoMZ+6SimPkfT5bElreXf8m9HnmPc3E1BQ==", + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.0.tgz", + "integrity": "sha512-Q1JFAoUKE5IMfI4Z/lkE/E6+SwgzO+x4tq4v1AyBLRj8VSYvRO6A/rQrPg1yud4g0En9EKI1TvFRF2tQFcoUkg==", "requires": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", diff --git a/package.json b/package.json index e49f76691..89deff820 100644 --- a/package.json +++ b/package.json @@ -26,12 +26,13 @@ "dayjs": "^1.11.10", "express": "^4.18.2", "htmlparser2": "^9.1.0", + "image-size": "^1.1.1", "less": "^4.2.0", "module-alias": "^2.2.3", "protobufjs": "^7.2.6", "qs": "^6.11.2", "sanitize-html": "^2.12.1", - "terser": "^5.30.4", + "terser": "^5.31.0", "ts-node": "^10.9.2", "tsc-alias": "^1.8.8", "typescript": "^5.3.3", diff --git a/plugins/russian/authortoday.ts b/plugins/russian/authortoday.ts index c12eba0e7..45d50d725 100644 --- a/plugins/russian/authortoday.ts +++ b/plugins/russian/authortoday.ts @@ -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 { - 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 { - 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 { 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(' { + 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; diff --git a/plugins/russian/ranobelib.ts b/plugins/russian/ranobelib.ts index 456289445..921c71b1a 100644 --- a/plugins/russian/ranobelib.ts +++ b/plugins/russian/ranobelib.ts @@ -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, ): Promise { - 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 novels: Plugin.NovelItem[] = []; - - this.ui = loadedCheerio('a.header-right-menu__item') - .attr('href') - ?.replace?.(/[^0-9]/g, ''); + const result: TopLevel = await fetchApi(url, { + headers: this.user?.token, + }).then(res => res.json()); - const novelsRaw = result.match(/window\.__CATALOG_ITEMS__ = (\[.*?\]);/); - if (novelsRaw instanceof Array && novelsRaw.length >= 2) { - const novelsJson: resNovels[] = JSON.parse(novelsRaw[1]); - novelsJson.forEach(novel => { + const novels: Plugin.NovelItem[] = []; + 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 { - 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(); - - novel.genres = loadedCheerio('div[class="media-tags"]') - .text() - .trim() - .replace(/[\n\r]+/g, ', ') - .replace(/ /g, ''); - - 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(); - - 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(); - } - }); - this.ui = loadedCheerio('a.header-right-menu__item') - .attr('href') - ?.replace?.(/[^0-9]/g, ''); + 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 chaptersRaw = body.match(/window\.__CHAPTERS__ = (\[.*?\]);/); - if (chaptersRaw instanceof Array && chaptersRaw.length >= 2) { - const chaptersJson: resChapters[] = JSON.parse(chaptersRaw[1]); + const branch_id: { [key: number]: string } = {}; + if (data.teams.length) { + data.teams.forEach( + ({ name, details }) => (branch_id[details?.branch_id || '0'] = name), + ); + } - 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 { - 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 { - 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 += '
'; + break; + case 'horizontalRule': + html += '
'; + break; + case 'image': + if (element.attrs) { + const attrs = Object.entries(element.attrs) + .filter(attr => attr?.[1]) + .map(attr => `${attr[0]}="${attr[1]}"`); + html += ''; + } + break; + case 'paragraph': + html += + '

' + + (element.content ? jsonToHtml(element.content) : '
') + + '

'; + 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; + username: string; + avatar: Cover; + last_online_at: string; + metadata: Metadata; +} +interface Metadata { + auth_domains: string; +} + +interface TopLevel { + data: DataClass | DataClass[]; + links?: Links; + meta?: Meta; +} + +interface AgeRestriction { + id: number; + label: string; +} + +interface Branch { + id: number; + branch_id: null; + created_at: string; + teams: BranchTeam[]; + user: User; +} + +interface BranchTeam { 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; + name: string; + cover: Cover; } + interface Cover { - filename: string; + filename: null | string; thumbnail: string; default: string; } -interface AgeRestrictionOrTypeOrStatus { + +interface User { + username: string; id: number; - label: string; } + interface Rating { average: string; - averageFormated: string; votes: number; votesFormated: string; - user: number; } -interface resChapters { +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; } diff --git a/scripts/icon_reloading.ts b/scripts/icon_reloading.ts index e10664a9f..7248c9654 100644 --- a/scripts/icon_reloading.ts +++ b/scripts/icon_reloading.ts @@ -1,12 +1,12 @@ import * as fs from 'fs'; -import { Plugin } from '@typings/plugin'; -import { languages } from '@libs/languages'; import * as path from 'path'; +import sizeOf from 'image-size'; const root = path.join(__dirname, '..'); const size = 96; +const minSize = 16; -const skip: string[] = [ +const skip = new Set([ //custom icons 'FWK.US', 'ReN', @@ -23,19 +23,14 @@ const skip: string[] = [ 'sektenovel', 'sonicmtl', 'translatinotaku', + 'warriorlegendtrad', 'wuxiaworld.site', +]); - //low quality - 'BLN', - 'NO.net', - 'novelbookid', - 'novelhall', - 'olaoe', -]; - -const used = new Set(); -used.add(path.join(root, 'icons', 'coverNotAvailable.webp')); -used.add(path.join(root, 'icons', 'siteNotAvailable.png')); +const used = new Set([ + path.join(root, 'icons', 'coverNotAvailable.webp'), + path.join(root, 'icons', 'siteNotAvailable.png'), +]); const notAvailableImage = fs.readFileSync( path.join(root, 'icons', 'siteNotAvailable.png'), @@ -54,6 +49,8 @@ const notAvailableImage = fs.readFileSync( let language; for (let plugin in plugins) { const { id, name, site, iconUrl, lang } = plugins[plugin]; + const icon = iconUrl && 'icons/' + iconUrl.split('icons/')[1]; + if (language !== lang) { language = lang; console.log( @@ -62,12 +59,10 @@ const notAvailableImage = fs.readFileSync( .padEnd(30, '='), ); } - let icon; - if (iconUrl) icon = 'icons/' + iconUrl.split('icons/')[1]; + try { if (icon) used.add(path.join(root, icon)); - - if (!skip.includes(id) && icon && site) { + if (!skip.has(id) && icon && site) { const image = await fetch( `https://www.google.com/s2/favicons?domain=${site}&sz=${size}&type=png`, ) @@ -85,8 +80,30 @@ const notAvailableImage = fs.readFileSync( continue; } - fs.writeFileSync(icon, image); - console.log(' ', name.padEnd(26), `(${id})`, '\r✅'); + const imageSize = sizeOf(image); + const exist = fs.existsSync(icon); + + if (!exist) { + const dir = icon.match(/^.*[\\\/]/)[0] as string; + fs.mkdirSync(dir, { recursive: true }); + } + + if ( + ((imageSize?.width || size) > minSize && + (imageSize?.height || size) > minSize) || + !exist + ) { + fs.writeFileSync(icon, image); + console.log(' ', name.padEnd(26), `(${id})`, '\r✅'); + } else { + console.log( + ' ', + name.padEnd(26), + `(${id})`.padEnd(20), + 'Low quality', + '\r🔄', + ); + } } else { console.log(' ', `Skipping ${name}`.padEnd(26), `(${id})`, '\r🔄'); } diff --git a/types/plugin.ts b/types/plugin.ts index a440e8daf..797e4c346 100644 --- a/types/plugin.ts +++ b/types/plugin.ts @@ -67,6 +67,8 @@ export namespace Plugin { site: string; filters?: Filters; version: string; + //flag indicates whether access to LocalStorage, SesesionStorage is required. + webStorageUtilized?: boolean; popularNovels( pageNo: number, options: PopularNovelsOptions,