import { fetchApi } from '@libs/fetch'; import { Plugin } from '@/types/plugin'; import { load as loadCheerio } from 'cheerio'; import { Filters, FilterTypes } from '@libs/filterInputs'; import { storage } from '@libs/storage'; import { defaultCover } from '@libs/defaultCover'; class FenrirRealmPlugin implements Plugin.PluginBase { id = 'fenrir'; name = 'Fenrir Realm'; icon = 'src/en/fenrirrealm/icon.png'; site = 'https://fenrirealm.com'; version = '1.0.10'; imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined; hideLocked = storage.get('hideLocked'); pluginSettings = { hideLocked: { value: '', label: 'Hide locked chapters', type: 'Switch', }, }; //flag indicates whether access to LocalStorage, SesesionStorage is required. webStorageUtilized?: boolean; async popularNovels( pageNo: number, { showLatestNovels, filters, }: Plugin.PopularNovelsOptions, ): Promise { // let sort = "updated"; let sort = filters.sort.value; if (showLatestNovels) sort = 'latest'; const genresFilter = filters.genres.value .map(g => '&genres%5B%5D=' + g) .join(''); const res = await fetchApi( `${this.site}/api/series/filter?page=${pageNo}&per_page=20&status=${filters.status.value}&order=${sort}${genresFilter}`, ).then(r => r.json()); return res.data.map(r => this.parseNovelFromApi(r)); } async parseNovel(novelPath: string): Promise { const html = await fetchApi(`${this.site}/series/${novelPath}`, { headers: { 'User-Agent': '', }, }).then(r => r.text()); const loadedCheerio = loadCheerio(html); const novel: Plugin.SourceNovel = { path: novelPath, name: loadedCheerio('h1.my-2').text(), summary: loadedCheerio( 'div.overflow-hidden.transition-all.max-h-\\[108px\\] p', ) .map((i, el) => loadCheerio(el).text()) .get() .join('\n\n'), }; // novel.artist = ''; novel.author = loadedCheerio( 'div.flex-1 > div.mb-3 > a.inline-flex', ).text(); const coverMatch = html.match(/,cover:"storage\/(.+?)",cover_data_url/); novel.cover = coverMatch ? this.site + '/storage/' + coverMatch[1] : defaultCover; novel.genres = loadedCheerio('div.flex-1 > div.flex:not(.mb-3, .mt-5) > a') .map((i, el) => loadCheerio(el).text()) .toArray() .join(','); novel.status = loadedCheerio('div.flex-1 > div.mb-3 > span.rounded-md') .first() .text(); let chapters = await fetchApi( this.site + '/api/novels/chapter-list/' + novelPath, ).then(r => r.json()); if (this.hideLocked) { chapters = chapters.filter(c => !c.locked?.price); } novel.chapters = chapters .map(c => ({ name: (c.locked?.price ? '🔒 ' : '') + (c.group?.index === null ? '' : 'Vol ' + c.group.index + ' ') + 'Chapter ' + c.number + (c.title && c.title.trim() != 'Chapter ' + c.number ? ' - ' + c.title.replace(/^chapter [0-9]+ . /i, '') : ''), path: novelPath + (c.group?.index === null ? '' : '/' + c.group.slug) + '/chapter-' + c.number, releaseTime: c.created_at, chapterNumber: c.number + c.group?.index * 1000000000000, })) .sort((a, b) => a.chapterNumber - b.chapterNumber); return novel; } async parseChapter(chapterPath: string): Promise { const page = await fetchApi(this.site + '/series/' + chapterPath, { headers: { 'User-Agent': '', }, }).then(r => r.text()); return loadCheerio(page)('#reader-area').html() || ''; } async searchNovels( searchTerm: string, pageNo: number, ): Promise { return await fetchApi( `${this.site}/api/series/filter?page=${pageNo}&per_page=20&search=${encodeURIComponent(searchTerm)}`, ) .then(r => r.json()) .then(r => r.data.map(novel => this.parseNovelFromApi(novel))); } parseNovelFromApi(apiData) { return { name: apiData.title, path: apiData.slug, cover: this.site + '/' + apiData.cover, summary: apiData.description, status: apiData.status, genres: apiData.genres.map(g => g.name).join(','), }; } resolveUrl = (path: string, isNovel?: boolean) => this.site + '/series/' + path; filters = { status: { type: FilterTypes.Picker, label: 'Status', value: 'any', options: [ { label: 'All', value: 'any' }, { label: 'Ongoing', value: 'ongoing' }, { label: 'Completed', value: 'completed', }, ], }, sort: { type: FilterTypes.Picker, label: 'Sort', value: 'popular', options: [ { label: 'Popular', value: 'popular' }, { label: 'Latest', value: 'latest' }, { label: 'Updated', value: 'updated' }, ], }, genres: { type: FilterTypes.CheckboxGroup, label: 'Genres', value: [], options: [ { 'label': 'Action', 'value': '1' }, { 'label': 'Adult', 'value': '2' }, { 'label': 'Adventure', 'value': '3', }, { 'label': 'Comedy', 'value': '4' }, { 'label': 'Drama', 'value': '5' }, { 'label': 'Ecchi', 'value': '6', }, { 'label': 'Fantasy', 'value': '7' }, { 'label': 'Gender Bender', 'value': '8' }, { 'label': 'Harem', 'value': '9', }, { 'label': 'Historical', 'value': '10' }, { 'label': 'Horror', 'value': '11' }, { 'label': 'Josei', 'value': '12', }, { 'label': 'Martial Arts', 'value': '13' }, { 'label': 'Mature', 'value': '14' }, { 'label': 'Mecha', 'value': '15', }, { 'label': 'Mystery', 'value': '16' }, { 'label': 'Psychological', 'value': '17' }, { 'label': 'Romance', 'value': '18', }, { 'label': 'School Life', 'value': '19' }, { 'label': 'Sci-fi', 'value': '20' }, { 'label': 'Seinen', 'value': '21', }, { 'label': 'Shoujo', 'value': '22' }, { 'label': 'Shoujo Ai', 'value': '23' }, { 'label': 'Shounen', 'value': '24', }, { 'label': 'Shounen Ai', 'value': '25' }, { 'label': 'Slice of Life', 'value': '26' }, { 'label': 'Smut', 'value': '27', }, { 'label': 'Sports', 'value': '28' }, { 'label': 'Supernatural', 'value': '29' }, { 'label': 'Tragedy', 'value': '30', }, { 'label': 'Wuxia', 'value': '31' }, { 'label': 'Xianxia', 'value': '32' }, { 'label': 'Xuanhuan', 'value': '33', }, { 'label': 'Yaoi', 'value': '34' }, { 'label': 'Yuri', 'value': '35' }, ], }, } satisfies Filters; } export default new FenrirRealmPlugin(); //paste into console on site to load async function getUpdatedGenres() { const data = await fetch( 'https://fenrirealm.com/api/novels/taxonomy/genres', ).then(d => d.json()); const genreData = data.map(g => ({ label: g.name, value: g.id.toString() })); console.log(JSON.stringify(genreData)); }