import { Parser } from 'htmlparser2'; import { fetchApi } from '@libs/fetch'; import { Plugin } from '@/types/plugin'; import { Filters, FilterTypes } from '@libs/filterInputs'; import { NovelStatus } from '@libs/novelStatus'; import { isUrlAbsolute } from '@libs/isAbsoluteUrl'; import { storage } from '@libs/storage'; class RoyalRoad implements Plugin.PluginBase { id = 'royalroad'; name = 'Royal Road'; version = '2.3.1'; icon = 'src/en/royalroad/icon.png'; site = 'https://www.royalroad.com/'; enableVol = storage.get('enableVol'); pluginSettings = { enableVol: { value: '', label: 'Enable Pagination / Volume view (not recommended)', type: 'Switch', }, }; parseNovels(html: string) { const baseUrl = this.site; const novels: Plugin.NovelItem[] = []; let tempNovel: Partial = {}; let state: ParsingState = ParsingState.Idle; const parser = new Parser({ onopentag(name, attribs) { if (attribs['class']?.includes('fiction-list-item')) { state = ParsingState.Novel; } if (state !== ParsingState.Novel) return; switch (name) { case 'a': if (attribs['href']) { tempNovel.path = attribs['href'].split('/').slice(1, 3).join('/'); } break; case 'img': if (attribs['src']) { tempNovel.name = attribs['alt'] || ''; tempNovel.cover = !isUrlAbsolute(attribs['src']) ? baseUrl + attribs['src'].slice(1) : attribs['src']; } break; } }, onclosetag(name) { if (name === 'figure') { if (tempNovel.path && tempNovel.name) { novels.push(tempNovel as Plugin.NovelItem); tempNovel = {}; } state = ParsingState.Idle; } }, }); parser.write(html); parser.end(); return novels; } async popularNovels( page: number, { filters, showLatestNovels, }: Plugin.PopularNovelsOptions, ): Promise { const params = new URLSearchParams({ page: page.toString(), }); if (showLatestNovels) { params.append('orderBy', 'last_update'); } if (!filters) filters = this.filters || {}; for (const key in filters) { if (filters[key as keyof typeof filters].value === '') continue; if (key === 'genres' || key === 'tags' || key === 'content_warnings') { if (filters[key].value.include) { for (const include of filters[key].value.include) { params.append('tagsAdd', include); } } if (filters[key].value.exclude) { for (const exclude of filters[key].value.exclude) { params.append('tagsRemove', exclude); } } } else { params.append(key, String(filters[key as keyof typeof filters].value)); } } const link = `${this.site}fictions/search?${params.toString()}`; const body = await fetchApi(link).then(r => r.text()); return this.parseNovels(body); } async parseNovel(novelPath: string): Promise { const result = await fetchApi(this.site + novelPath); const html = await result.text(); const novel: Partial = { path: novelPath, }; const baseUrl = this.site; const enableVolume = this.enableVol; let state: ParsingState = ParsingState.Idle; let statusText = ''; let statusSpanCounter = 0; const nameParts: string[] = []; const summaryParts: string[] = []; const scriptContentParts: string[] = []; const genreArray: string[] = []; let chapterJson: ChapterEntry[] = []; let volumeJson: VolumeEntry[] = []; const parser = new Parser({ onopentag(name, attribs) { switch (name) { case 'h1': state = ParsingState.InTitle; break; case 'a': if (attribs['href']?.startsWith('/profile/') && !novel.author) { state = ParsingState.InAuthor; } else if (state === ParsingState.InTags) { state = ParsingState.InTagLink; } break; case 'div': if (attribs['class'] === 'description') { state = ParsingState.InDescription; } break; case 'hr': if (state === ParsingState.InDescription) { summaryParts.push('\n\n---\n\n'); } break; case 'br': if (state === ParsingState.InDescription) { summaryParts.push('\n\n'); } break; case 'span': if (attribs['class']?.includes('tags')) { state = ParsingState.InTags; } else if (attribs['class']?.includes('label-sm')) { statusSpanCounter++; if (statusSpanCounter === 2) { state = ParsingState.InStatusSpan; statusText = ''; } } break; case 'img': if (attribs['class']?.includes('thumbnail')) { novel.cover = attribs['src']; if (novel.cover && !isUrlAbsolute(novel.cover)) { novel.cover = baseUrl + novel.cover.slice(1); } } break; case 'script': state = ParsingState.InScript; break; } }, ontext(text) { const trimmedText = text.trim(); if (!trimmedText && state !== ParsingState.InScript) return; switch (state) { case ParsingState.InTitle: nameParts.push(text); break; case ParsingState.InAuthor: novel.author = trimmedText; break; case ParsingState.InDescription: summaryParts.push(text); break; case ParsingState.InStatusSpan: statusText = trimmedText; break; case ParsingState.InTagLink: genreArray.push(trimmedText); break; case ParsingState.InScript: scriptContentParts.push(text); break; } }, onclosetag(name) { switch (name) { case 'h1': if (state === ParsingState.InTitle) { novel.name = nameParts.join('').trim(); state = ParsingState.Idle; } break; case 'a': if (state === ParsingState.InTagLink) { state = ParsingState.InTags; } else if (state === ParsingState.InAuthor) { state = ParsingState.Idle; } break; case 'p': if (state === ParsingState.InDescription) { summaryParts.push('\n\n'); } break; case 'div': if (state === ParsingState.InDescription) { novel.summary = summaryParts .join('') .replace(/ /g, ' ') .replace(/\n{3,}/g, '\n\n') .trim(); summaryParts.length = 0; state = ParsingState.Idle; } break; case 'span': if (state === ParsingState.InTags) { novel.genres = genreArray.join(', '); state = ParsingState.Idle; } else if (state === ParsingState.InStatusSpan) { state = ParsingState.Idle; } break; case 'script': if (state === ParsingState.InScript) { state = ParsingState.Idle; const scriptContent = scriptContentParts.join(''); const chapterMatch = scriptContent.match( /window\.chapters\s*=\s*(\[.*?\]);/, ); const volumeMatch = scriptContent.match( /window\.volumes\s*=\s*(\[.*?\]);/, ); if (chapterMatch?.[1]) { chapterJson = JSON.parse(chapterMatch[1]); } if (volumeMatch?.[1] && enableVolume) { volumeJson = JSON.parse(volumeMatch[1]); } } break; } }, onend() { switch (statusText) { case 'ONGOING': novel.status = NovelStatus.Ongoing; break; case 'HIATUS': novel.status = NovelStatus.OnHiatus; break; case 'COMPLETED': novel.status = NovelStatus.Completed; break; default: novel.status = NovelStatus.Unknown; } novel.chapters = chapterJson.map((chapter: ChapterEntry) => { const matchingVolume = volumeJson.find( (volume: VolumeEntry) => volume.id === chapter.volumeId, ); return { name: chapter.title, path: (() => { const parts = chapter.url.split('/'); return `${parts[1]}/${parts[2]}/${parts[4]}/${parts[5]}`; })(), releaseTime: chapter.date, chapterNumber: chapter?.order, page: matchingVolume?.title, }; }); }, }); parser.write(html); parser.end(); return novel as Plugin.NovelItem; } async parseChapter(chapterPath: string): Promise { const result = await fetchApi(this.site + chapterPath); const html = await result.text(); let state = ParsingState.Idle; let stateDepth = 0; let depth = 0; const chapterHtmlParts: string[] = []; const notesHtmlParts: string[] = []; const beforeNotesParts: string[] = []; const afterNotesParts: string[] = []; let isBeforeChapter = true; const match = html.match(/