import { Parser } from 'htmlparser2'; import { fetchApi, fetchFile } from '@libs/fetch'; import { Plugin } from '@typings/plugin'; import { Filters, FilterTypes } from '@libs/filterInputs'; import { NovelStatus } from '@libs/novelStatus'; class RoyalRoad implements Plugin.PluginBase { id = 'royalroad'; name = 'Royal Road'; version = '2.0.0'; icon = 'src/en/royalroad/icon.png'; site = 'https://www.royalroad.com/'; parseNovels(html: string) { const novels: Plugin.NovelItem[] = []; let tempNovel = {} as Plugin.NovelItem; tempNovel.name = ''; let isParsingNovel = false; let isNovelName = false; const parser = new Parser({ onopentag(name, attribs){ if (attribs['class']?.includes('fiction-list-item')){ isParsingNovel = true; } if (isParsingNovel){ if (name === 'a' && attribs['class']?.includes('bold')){ tempNovel.path = attribs['href'].slice(1); isNovelName = true; } if (name === 'img'){ tempNovel.cover = attribs['src']; } if (tempNovel.path && tempNovel.name){ novels.push(tempNovel); tempNovel = {} as Plugin.NovelItem; tempNovel.name = ''; } } }, ontext(data){ if(isNovelName){ tempNovel.name += data; } }, onclosetag(name){ if(name === 'h2'){ isNovelName = false; isParsingNovel = false; } } }) parser.write(html); parser.end(); return novels; } async popularNovels( page: number, { filters }: Plugin.PopularNovelsOptions, ): Promise { let link = `${this.site}fictions/`; link += filters.order.value; link += `?page=${page}`; if (filters.genre.value !== '') link += `&genre=${filters.genre.value}`; 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: Plugin.SourceNovel = { path: novelPath, name: '', summary: '', chapters: [], } let isNovelName = false; let isAuthorName = false; let isDescription = false; let isH4 = false; let isSpan = 0 let isTags = false; let isGenres = false; let genreArray: string[] = [] let isFooter = false; let isScript = false; let chapterJson: ChapterEntry[] = []; let volumeJson: VolumeEntry[] = []; const parser = new Parser ({ onopentag(name,attribs){ if (name === 'img' && attribs['class']?.includes('thumbnail')){ novel.cover = attribs['src']; } if (name === 'span' && attribs['class']?.includes('label-sm')){ isSpan++; } if (name === 'span' && attribs['class']?.includes('tags')){ isTags = true; } }, onopentagname(name){ if (name === 'h1'){ isNovelName = true; } if (isH4 && name === 'a'){ isAuthorName = true; } if(isTags && name === 'a'){ isGenres = true; } if (name === 'label'){ isDescription = false; isTags = false; } if (isFooter && name === 'script'){ isScript = true; } }, onattribute(name,value){ if (name === 'class' && value === 'description'){ isDescription = true; } if (name === 'class' && value === 'page-footer footer'){ isFooter = true; } }, ontext(data){ if (isNovelName){ novel.name = data; } if (isAuthorName){ novel.author = data; isAuthorName = false; } if (isDescription){ novel.summary += data; } if (isSpan === 2){ novel.status = data.trim(); isSpan++; } if (isGenres){ genreArray.push(data); } if (isScript){ if (data.includes('window.chapters =')){ chapterJson = JSON.parse( data.match(/window.chapters = (.+])(?=;)/)![1] ) volumeJson = JSON.parse( data.match(/window.volumes = (.+])(?=;)/)![1] ) } } }, onclosetag(name){ if (name === 'h1'){ isNovelName = false; isH4 = true; } if (name === 'h4'){ isH4 = false; } if (name === 'a'){ isGenres = false; } if (name === 'script'){ isScript = false; } if (name === 'body'){ isFooter = false; } } }) parser.write(html); parser.end(); novel.summary = novel.summary?.trim(); novel.genres = genreArray.join(', '); switch(novel.status){ 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; } const chapter: Plugin.ChapterItem[] = chapterJson.map( (chapter: ChapterEntry) => { const matchingVolume = volumeJson.find( (volume: VolumeEntry) => volume.id === chapter.volumeId, ); return { name: chapter.title, path: chapter.url.slice(1), releaseTime: chapter.date, chapterNumber: chapter?.order, page: matchingVolume?.title, }; }, ); novel.chapters = chapter; return novel; } async parseChapter(chapterPath: string): Promise { const result = await fetchApi(this.site + chapterPath); const html = await result.text(); let chapterText = ''; let isChapter = false; let isPtag = false; let isStyle = false; let isStyleText = false; let styles = ''; const parser = new Parser({ onopentag(name,attribs){ if (isChapter && name === 'div'){ let stylediv = attribs['style'] if(stylediv){ chapterText += `
` isStyleText = true; } else { chapterText += `
` } } if (isChapter && name === 'table'){ let w = attribs['width']; if(w) { chapterText += ``; } else { chapterText += `
`; } } if (isChapter && name === 'tbody'){ chapterText += ``; } if (isChapter && name === 'tr'){ chapterText += ``; } if (isChapter && name === 'td'){ let w1 = attribs['width']; if(w1) { chapterText += ``; } if (isChapter && name === 'tr'){ chapterText += ``; } if (isChapter && name === 'tbody'){ chapterText += ``; } if (isChapter && name === 'table'){ chapterText += `
`; } else { chapterText += ``; } } }, onattribute(name, value){ if (name === 'class' && value === 'chapter-inner chapter-content'){ isChapter = true; } if (name === 'class' && value === 'portlet light t-center-3'){ isChapter = false; isPtag = false; } if (name === 'class' && value === styles){ isPtag = false; } }, onopentagname(name){ if (isChapter && name === 'p'){ chapterText += '

'; isPtag = true; if(isStyleText){ isStyleText = false; } } if (name === 'style'){ isStyle = true; } if (isChapter && name === 'br'){ chapterText += `
`; } }, ontext(data){ if(isPtag){ chapterText += data; } if(isStyleText){ chapterText += data; } if (isStyle){ if (data.includes('display: none;')){ styles = data.match(/\.(.*)\{/)![1]; } } }, onclosetag(name){ if (name === 'p'){ isPtag = false; chapterText += '

' } if (name === 'style'){ isStyle = false; } if (isChapter && name === 'td'){ chapterText += `
`; } if (isChapter && name === 'div'){ isStyleText = false; chapterText += `
` } } }) parser.write(html) parser.end(); return chapterText; } async searchNovels( searchTerm: string, page: number, ): Promise { const searchUrl = `${this.site}fictions/search?page=${page}&title=${searchTerm}`; const body = await fetchApi(searchUrl).then(r => r.text()); return this.parseNovels(body); } async fetchImage(url: string): Promise { return fetchFile(url); } filters = { order: { value: 'weekly-popular', label: 'Order By', options: [ { label: 'Best Rated', value: 'best-rated' }, { label: 'Trending', value: 'trending' }, { label: 'Ongoing Fictions', value: 'active-popular' }, { label: 'Complete', value: 'complete' }, { label: 'Popular this week', value: 'weekly-popular' }, { label: 'Latest Updates', value: 'latest-updates' }, { label: 'Newest Fictions', value: 'new' }, { label: 'Rising Stars', value: 'rising-stars' }, { label: 'Writathon', value: 'writathon' }, ], type: FilterTypes.Picker, }, genre: { value: '', label: 'Genres', options: [ { label: 'ALL', value: '' }, { label: 'Action', value: 'action' }, { label: 'Adventure', value: 'adventure' }, { label: 'Comedy', value: 'comedy' }, { label: 'Contemporary', value: 'contemporary' }, { label: 'Drama', value: 'drama' }, { label: 'Fantasy', value: 'fantasy' }, { label: 'Historical', value: 'historical' }, { label: 'Horror', value: 'horror' }, { label: 'Mystery', value: 'mystery' }, { label: 'Psychological', value: 'psychological' }, { label: 'Romance', value: 'romance' }, { label: 'Satire', value: 'satire' }, { label: 'Sci-fi', value: 'sci_fi' }, { label: 'Short Story', value: 'one_shot' }, { label: 'Tragedy', value: 'tragedy' }, ], type: FilterTypes.Picker, }, } satisfies Filters; } export default new RoyalRoad(); interface ChapterEntry { id: number; volumeId: number; title: string; date: string; order: number; url: string; } interface VolumeEntry { id: number; title: string; cover: string; order: number; }