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'; type APINovel = { title: string; slug: string; cover: string; description: string; status: string; genres: { name: string }[]; user?: { username?: string; name?: string; }; }; type APIChapter = { id: number; locked: { price: number } | null; group: null | { index: number; slug: string; }; title: string; slug: string; number: number; created_at: string; }; type ChapterInfo = { name: string; path: string; releaseTime: string; chapterNumber: number; }; type Chapter = { type: string; content: { type: string; attrs?: Attrs; content: { type: string; text?: string; marks?: { type: string; attrs?: Attrs; }[]; }[]; }[]; }; type Attrs = { textAlign?: string; href?: string; level?: number; }; type Nodes = { type: string; nodes?: { type: string; // couldnt find the sveltekit so disable eslint here // eslint-disable-next-line @typescript-eslint/no-explicit-any data: any[]; }[]; }; class FenrirRealmPlugin implements Plugin.PluginBase { id = 'fenrir'; name = 'Fenrir Realm'; icon = 'src/en/fenrirrealm/icon.png'; site = 'https://fenrirealm.com'; version = '1.1.2'; 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 { const params = new URLSearchParams({ page: pageNo.toString(), per_page: '20', status: filters.status.value, order: showLatestNovels ? 'latest' : filters.sort.value, }); filters.genres.value.forEach(g => params.append('genres[]', g)); const res = await fetchApi( `${this.site}/api/series/filter?${params.toString()}`, ).then(r => r.json().catch(() => { throw new Error( 'There was an error fetching the data from the server. Please try to open it in WebView', ); }), ); return (res.data || []).map((r: APINovel) => this.parseNovelFromApi(r)); } async parseNovel(novelPath: string): Promise { let cleanNovelPath = novelPath; let apiRes = await fetchApi( `${this.site}/api/new/v2/series/${novelPath}/chapters`, {}, ); if (!apiRes.ok) { const slugMatch = novelPath.match(/^\d+-(.+)$/); const searchSlug = slugMatch ? slugMatch[1] : novelPath; apiRes = await fetchApi( `${this.site}/api/new/v2/series/${searchSlug}/chapters`, {}, ); cleanNovelPath = searchSlug; if (!apiRes.ok) { const words = searchSlug.replace(/-/g, ' ').split(' '); const SearchStr = words.find(w => w.length > 3) || words[0]; const searchRes = await fetchApi( `${this.site}/api/series/filter?page=1&per_page=20&search=${encodeURIComponent(SearchStr)}`, ).then(r => r.json()); if (searchRes.data && searchRes.data.length > 0) { cleanNovelPath = searchRes.data[0].slug; apiRes = await fetchApi( `${this.site}/api/new/v2/series/${cleanNovelPath}/chapters`, {}, ); } } if (!apiRes.ok) { throw new Error( 'Novel not found. It may have been removed or its URL changed significantly.', ); } } const seriesData: APINovel = await fetchApi( `${this.site}/api/new/v2/series/${cleanNovelPath}`, ).then(r => r.json()); const summaryCheerio = loadCheerio(seriesData.description || ''); const novel: Plugin.SourceNovel = { path: cleanNovelPath, name: seriesData.title || '', summary: summaryCheerio('p').length > 0 ? summaryCheerio('p') .map((_, el) => loadCheerio(el).text()) .get() .join('\n\n') : summaryCheerio.text() || '', author: seriesData.user?.name || seriesData.user?.username || '', cover: seriesData.cover ? this.site + '/' + seriesData.cover : defaultCover, genres: (seriesData.genres || []).map(g => g.name).join(','), status: seriesData.status || 'Unknown', }; let chapters = await apiRes.json(); if (this.hideLocked) { chapters = chapters.filter((c: APIChapter) => !c.locked?.price); } novel.chapters = chapters .map((c: APIChapter) => ({ 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) + '/' + (c.slug || 'chapter-' + c.number) + '~~' + c.id, releaseTime: c.created_at, chapterNumber: c.number + (c.group?.index || 0) * 10000, })) .sort( (a: ChapterInfo, b: ChapterInfo) => a.chapterNumber - b.chapterNumber, ); return novel; } async parseChapter(chapterPath: string): Promise { const chapterId = chapterPath.split('~~')[1]; if (chapterId) { const url = `${this.site}/api/new/v2/chapters/${chapterId}`; const res = await fetchApi(url); const json = await res.json(); const content = json.content; if (content) { try { // Attempt 1: Handle legacy stringified TipTap JSON AST schema const parsedContent: Chapter = JSON.parse(content); if (parsedContent.type === 'doc') { return parsedContent.content .map(node => { if (node.type === 'paragraph') { const innerHtml = node.content ?.map(c => { if (c.type === 'text') { let text = c.text; if (c.marks) { for (const mark of c.marks) { if (mark.type === 'bold') text = `${text}`; if (mark.type === 'italic') text = `${text}`; if (mark.type === 'underline') text = `${text}`; if (mark.type === 'strike') text = `${text}`; if (mark.type === 'link') text = `${text}`; } } return text; } if (c.type === 'hardBreak') return '
'; return ''; }) .join('') || ''; return `

${innerHtml}

`; } if (node.type === 'heading') { const level = node.attrs?.level || 1; const innerHtml = node.content?.map(c => c.text).join('') || ''; return `${innerHtml}`; } return ''; }) .join('\n'); } } catch { /** * HTML Response Cleaner: * Fenrir Realm inserts anti-scraping & watermark elements: * 1. Scramble hidden divs (`div[aria-hidden="true"]`) * 2. Inline `