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(/