diff --git a/icons/src/en/libread/icon.png b/icons/src/en/libread/icon.png new file mode 100644 index 0000000..1cda727 Binary files /dev/null and b/icons/src/en/libread/icon.png differ diff --git a/plugins/english/LibRead.ts b/plugins/english/LibRead.ts new file mode 100644 index 0000000..8e37c35 --- /dev/null +++ b/plugins/english/LibRead.ts @@ -0,0 +1,216 @@ +import { Plugin } from '@typings/plugin'; +import { fetchApi, fetchFile } from '@libs/fetch'; +import { CheerioAPI, load as parseHTML } from 'cheerio'; +import qs from 'qs'; +import { Filters, FilterTypes } from '@libs/filterInputs'; + +class LibReadPlugin implements Plugin.PluginBase { + id = 'libread'; + name = 'Lib Read'; + icon = 'src/en/libread/icon.png'; + site = 'https://libread.org'; + version = '1.0.0'; + + async getCheerio(url: string): Promise { + const r = await fetchApi(url); + if (!r.ok) + throw new Error( + `Could not reach site (${r.status}: ${r.statusText}) try to open in webview.`, + ); + return parseHTML(await r.text()); + } + + parseNovels(loadedCheerio: CheerioAPI): Plugin.NovelItem[] { + return loadedCheerio('.li-row') + .map((index, element) => ({ + name: loadedCheerio(element).find('.tit').text() || '', + cover: loadedCheerio(element).find('img').attr('src'), + path: loadedCheerio(element).find('h3 > a').attr('href') || '', + })) + .get() + .filter(novel => novel.name && novel.path); + } + + async popularNovels( + page: number, + { + showLatestNovels, + filters, + }: Plugin.PopularNovelsOptions, + ): Promise { + let url = this.site; + if (showLatestNovels) url += '/sort/latest-novels/'; + else { + if ( + filters && + filters.type_genre && + filters.type_genre.value !== 'all' && + filters.type_genre.value !== 'genre' + ) + url += filters.type_genre.value; + else { + url += '/most-popular/'; + if (page != 1) return []; + page = 0; + } + } + url += page; + + const $ = await this.getCheerio(url); + return this.parseNovels($); + } + + async parseNovel(novelPath: string): Promise { + const loadedCheerio = await this.getCheerio(this.site + novelPath); + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: loadedCheerio('h1.tit').text(), + cover: loadedCheerio('.pic > img').attr('src'), + summary: loadedCheerio('.inner').text().trim(), + }; + + novel.genres = loadedCheerio('[title=Genre]') + .next() + .text() + .replace(/[\t\n]/g, ''); + + novel.author = loadedCheerio('[title=Author]') + .next() + .text() + .replace(/[\t\n]/g, ''); + + novel.status = loadedCheerio('[title=Status]') + .next() + .text() + .replace(/[\t\n]/g, ''); + + novel.genres = loadedCheerio('[title=Genre]') + .next() + .text() + .trim() + .replace(/[\t\n]/g, ',') + .replace(/, /g, ','); + + const chapters: Plugin.ChapterItem[] = loadedCheerio('#idData > li > a') + .map((chapterIndex, element) => ({ + name: + loadedCheerio(element).attr('title') || + 'Chapter ' + (chapterIndex + 1), + path: + loadedCheerio(element).attr('href') || + novelPath.replace( + '.html', + '/chapter-' + (chapterIndex + 1) + '.html', + ), + releaseTime: null, + chapterNumber: chapterIndex + 1, + })) + .get(); + + novel.chapters = chapters; + return novel; + } + + async parseChapter(chapterPath: string): Promise { + const loadedCheerio = await this.getCheerio(this.site + chapterPath); + + // remove freewebnovel signature + if (loadedCheerio('style').text().includes('p:nth-last-child(1)')) + loadedCheerio('div.txt').find('p:last-child').remove(); + + const chapterText = loadedCheerio('div.txt').html() || ''; + return chapterText; + } + + async searchNovels(searchTerm: string): Promise { + const r = await fetchApi(this.site + '/search/', { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Referer: this.site, + Origin: this.site, + }, + method: 'POST', + body: qs.stringify({ searchkey: searchTerm }), + }); + if (!r.ok) + throw new Error( + 'Could not reach site (' + r.status + ') try to open in webview.', + ); + + const loadedCheerio = parseHTML(await r.text()); + const alertText = + loadedCheerio('script') + .text() + .match(/alert\((.*?)\)/)?.[1] || ''; + if (alertText) throw new Error(alertText); + + return this.parseNovels(loadedCheerio); + } + + fetchImage = fetchFile; + + filters = { + type_genre: { + type: FilterTypes.Picker, + label: 'Novel Type/Genre', + value: 'all', + options: [ + { label: 'Tous', value: 'all' }, + { label: '═══NOVEL TYPES═══', value: '/sort/latest-release/' }, + { + label: 'Chinese Novel', + value: '/sort/latest-release/chinese-novel/', + }, + { label: 'Korean Novel', value: '/sort/latest-release/korean-novel/' }, + { + label: 'Japanese Novel', + value: '/sort/latest-release/japanese-novel/', + }, + { + label: 'English Novel', + value: '/sort/latest-release/english-novel/', + }, + { label: '═══GENRES═══', value: 'genre' }, + { label: 'Action', value: '/genre/Action/' }, + { label: 'Adult', value: '/genre/Adult/' }, + { label: 'Adventure', value: '/genre/Adventure/' }, + { label: 'Comedy', value: '/genre/Comedy/' }, + { label: 'Drama', value: '/genre/Drama/' }, + { label: 'Eastern', value: '/genre/Eastern/' }, + { label: 'Ecchi', value: '/genre/Ecchi/' }, + { label: 'Fantasy', value: '/genre/Fantasy/' }, + { label: 'Game', value: '/genre/Game/' }, + { label: 'Gender Bender', value: '/genre/Gender+Bender/' }, + { label: 'Harem', value: '/genre/Harem/' }, + { label: 'Historical', value: '/genre/Historical/' }, + { label: 'Horror', value: '/genre/Horror/' }, + { label: 'Josei', value: '/genre/Josei/' }, + { label: 'Martial Arts', value: '/genre/Martial+Arts/' }, + { label: 'Mature', value: '/genre/Mature/' }, + { label: 'Mecha', value: '/genre/Mecha/' }, + { label: 'Mystery', value: '/genre/Mystery/' }, + { label: 'Psychological', value: '/genre/Psychological/' }, + { label: 'Reincarnation', value: '/genre/Reincarnation/' }, + { label: 'Romance', value: '/genre/Romance/' }, + { label: 'School Life', value: '/genre/School+Life/' }, + { label: 'Sci-fi', value: '/genre/Sci-fi/' }, + { label: 'Seinen', value: '/genre/Seinen/' }, + { label: 'Shoujo', value: '/genre/Shoujo/' }, + { label: 'Shounen Ai', value: '/genre/Shounen+Ai/' }, + { label: 'Shounen', value: '/genre/Shounen/' }, + { label: 'Slice of Life', value: '/genre/Slice+of+Life/' }, + { label: 'Smut', value: '/genre/Smut/' }, + { label: 'Sports', value: '/genre/Sports/' }, + { label: 'Supernatural', value: '/genre/Supernatural/' }, + { label: 'Tragedy', value: '/genre/Tragedy/' }, + { label: 'Wuxia', value: '/genre/Wuxia/' }, + { label: 'Xianxia', value: '/genre/Xianxia/' }, + { label: 'Xuanhuan', value: '/genre/Xuanhuan/' }, + { label: 'Yaoi', value: '/genre/Yaoi/' }, + ], + }, + } satisfies Filters; +} + +export default new LibReadPlugin(); diff --git a/plugins/english/freewebnovel.ts b/plugins/english/freewebnovel.ts index 9fa214f..518c663 100644 --- a/plugins/english/freewebnovel.ts +++ b/plugins/english/freewebnovel.ts @@ -1,30 +1,28 @@ import { Plugin } from '@typings/plugin'; import { fetchApi, fetchFile } from '@libs/fetch'; import { NovelStatus } from '@libs/novelStatus'; -import { load as parseHTML } from 'cheerio'; +import { CheerioAPI, load as parseHTML } from 'cheerio'; import qs from 'qs'; +import { Filters, FilterTypes } from '@libs/filterInputs'; class FreeWebNovel implements Plugin.PluginBase { id = 'FWN.com'; name = 'Free Web Novel'; site = 'https://freewebnovel.com'; - version = '1.0.0'; + version = '1.0.1'; icon = 'src/en/freewebnovel/icon.png'; - async popularNovels( - page: number, - { showLatestNovels }: Plugin.PopularNovelsOptions, - ): Promise { - const sort = showLatestNovels - ? '/latest-release-novels/' - : '/completed-novels/'; + async getCheerio(url: string): Promise { + const r = await fetchApi(url); + if (!r.ok) + throw new Error( + `Could not reach site (${r.status}: ${r.statusText}) try to open in webview.`, + ); + return parseHTML(await r.text()); + } - const body = await fetchApi(this.site + sort + page).then(res => - res.text(), - ); - const loadedCheerio = parseHTML(body); - - const novels: Plugin.NovelItem[] = loadedCheerio('.li-row') + parseNovels(loadedCheerio: CheerioAPI): Plugin.NovelItem[] { + return loadedCheerio('.li-row') .map((index, element) => ({ name: loadedCheerio(element).find('.tit').text() || '', cover: loadedCheerio(element).find('img').attr('src'), @@ -32,13 +30,34 @@ class FreeWebNovel implements Plugin.PluginBase { })) .get() .filter(novel => novel.name && novel.path); + } - return novels; + async popularNovels( + page: number, + { + showLatestNovels, + filters, + }: Plugin.PopularNovelsOptions, + ): Promise { + let url = this.site; + if (showLatestNovels) url += '/latest-release-novels/'; + else { + if (filters && filters.genres && filters.genres.value !== 'all') + url += filters.genres.value; + else { + url += '/most-popular-novels/'; + if (page != 1) return []; + page = 0; + } + } + url += page; + + const $ = await this.getCheerio(url); + return this.parseNovels($); } async parseNovel(novelPath: string): Promise { - const body = await fetchApi(this.site + novelPath).then(res => res.text()); - const loadedCheerio = parseHTML(body); + const loadedCheerio = await this.getCheerio(this.site + novelPath); const novel: Plugin.SourceNovel = { path: novelPath, @@ -62,6 +81,13 @@ class FreeWebNovel implements Plugin.PluginBase { .text() .replace(/[\t\n]/g, ''); + novel.genres = loadedCheerio('[title=Genre]') + .next() + .text() + .trim() + .replace(/[\t\n]/g, ',') + .replace(/, /g, ','); + const chapters: Plugin.ChapterItem[] = loadedCheerio('#idData > li > a') .map((chapterIndex, element) => ({ name: @@ -83,17 +109,18 @@ class FreeWebNovel implements Plugin.PluginBase { } async parseChapter(chapterPath: string): Promise { - const body = await fetchApi(this.site + chapterPath).then(res => - res.text(), - ); - const loadedCheerio = parseHTML(body); + const loadedCheerio = await this.getCheerio(this.site + chapterPath); + + // remove freewebnovel signature + if (loadedCheerio('style').text().includes('p:nth-last-child(1)')) + loadedCheerio('div.txt').find('p:last-child').remove(); const chapterText = loadedCheerio('div.txt').html() || ''; return chapterText; } async searchNovels(searchTerm: string): Promise { - const body = await fetchApi(this.site + '/search/', { + const r = await fetchApi(this.site + '/search/', { headers: { 'Content-Type': 'application/x-www-form-urlencoded', Referer: this.site, @@ -101,22 +128,69 @@ class FreeWebNovel implements Plugin.PluginBase { }, method: 'POST', body: qs.stringify({ searchkey: searchTerm }), - }).then(res => res.text()); + }); + if (!r.ok) + throw new Error( + 'Could not reach site (' + r.status + ') try to open in webview.', + ); - const loadedCheerio = parseHTML(body); - const novels: Plugin.NovelItem[] = loadedCheerio('.li-row > .li > .con') - .map((index, element) => ({ - name: loadedCheerio(element).find('.tit').text() || '', - cover: loadedCheerio(element).find('.pic > a > img').attr('src'), - path: loadedCheerio(element).find('h3 > a').attr('href') || '', - })) - .get() - .filter(novel => novel.name && novel.path); + const loadedCheerio = parseHTML(await r.text()); + const alertText = + loadedCheerio('script') + .text() + .match(/alert\((.*?)\)/)?.[1] || ''; + if (alertText) throw new Error(alertText); - return novels; + return this.parseNovels(loadedCheerio); } fetchImage = fetchFile; + + filters = { + genres: { + type: FilterTypes.Picker, + label: 'Genre', + value: 'all', + options: [ + { label: 'Action', value: '/genres/Action/' }, + { label: 'Adult', value: '/genres/Adult/' }, + { label: 'Adventure', value: '/genres/Adventure/' }, + { label: 'Comedy', value: '/genres/Comedy/' }, + { label: 'Drama', value: '/genres/Drama/' }, + { label: 'Eastern', value: '/genres-novel/Eastern' }, + { label: 'Ecchi', value: '/genres/Ecchi/' }, + { label: 'Fantasy', value: '/genres/Fantasy/' }, + { label: 'Gender Bender', value: '/genres/Gender+Bender/' }, + { label: 'Harem', value: '/genres/Harem/' }, + { label: 'Historical', value: '/genres/Historical/' }, + { label: 'Horror', value: '/genres/Horror/' }, + { label: 'Josei', value: '/genres/Josei/' }, + { label: 'Game', value: '/genres/Game/' }, + { label: 'Martial Arts', value: '/genres/Martial+Arts/' }, + { label: 'Mature', value: '/genres/Mature/' }, + { label: 'Mecha', value: '/genres/Mecha/' }, + { label: 'Mystery', value: '/genres/Mystery/' }, + { label: 'Psychological', value: '/genres/Psychological/' }, + { label: 'Reincarnation', value: '/genres-novel/Reincarnation' }, + { label: 'Romance', value: '/genres/Romance/' }, + { label: 'School Life', value: '/genres/School+Life/' }, + { label: 'Sci-fi', value: '/genres/Sci-fi/' }, + { label: 'Seinen', value: '/genres/Seinen/' }, + { label: 'Shoujo', value: '/genres/Shoujo/' }, + { label: 'Shounen Ai', value: '/genres/Shounen+Ai/' }, + { label: 'Shounen', value: '/genres/Shounen/' }, + { label: 'Slice of Life', value: '/genres/Slice+of+Life/' }, + { label: 'Smut', value: '/genres/Smut/' }, + { label: 'Sports', value: '/genres/Sports/' }, + { label: 'Supernatural', value: '/genres/Supernatural/' }, + { label: 'Tragedy', value: '/genres/Tragedy/' }, + { label: 'Wuxia', value: '/genres/Wuxia/' }, + { label: 'Xianxia', value: '/genres/Xianxia/' }, + { label: 'Xuanhuan', value: '/genres/Xuanhuan/' }, + { label: 'Yaoi', value: '/genres/Yaoi/' }, + ], + }, + } satisfies Filters; } export default new FreeWebNovel(); diff --git a/scripts/json_plugins.ts b/scripts/json_plugins.ts index 806d02c..937bae5 100644 --- a/scripts/json_plugins.ts +++ b/scripts/json_plugins.ts @@ -87,14 +87,12 @@ json.sort((a, b) => { fs.writeFileSync(jsonMinPath, JSON.stringify(json)); fs.writeFileSync(jsonPath, JSON.stringify(json, null, '\t')); -fetch(`https://img.shields.io/badge/Plugins-${totalPlugins}-blue`) - .then(res => res.text()) - .then(data => { - fs.writeFileSync('total.svg', data, { encoding: 'utf-8' }); - }) - .catch(error => { - fs.writeFileSync('total.svg', '', { encoding: 'utf-8' }); - }); +fs.writeFileSync( + 'total.svg', + ` + Plugins: ${totalPlugins}Plugins${totalPlugins} + `, +); // check for broken plugins for (let language in languages) {