RoyalRoad: Replace regex with Parser (#1622)

* dom-serialize

* prettier fix

* use Parser only

* prettier fix pt 2

* use URLSearchParams for popular and fix default cover

* prettier trilogy (missing ;)
This commit is contained in:
K1ngfish3r
2025-04-09 18:19:53 +05:00
committed by GitHub
parent 42e77596e9
commit 76c803ce73
+125 -38
View File
@@ -3,20 +3,21 @@ import { fetchApi } from '@libs/fetch';
import { Plugin } from '@typings/plugin'; import { Plugin } from '@typings/plugin';
import { Filters, FilterTypes } from '@libs/filterInputs'; import { Filters, FilterTypes } from '@libs/filterInputs';
import { NovelStatus } from '@libs/novelStatus'; import { NovelStatus } from '@libs/novelStatus';
import { isUrlAbsolute } from '@libs/isAbsoluteUrl';
class RoyalRoad implements Plugin.PluginBase { class RoyalRoad implements Plugin.PluginBase {
id = 'royalroad'; id = 'royalroad';
name = 'Royal Road'; name = 'Royal Road';
version = '2.1.4'; version = '2.2.0';
icon = 'src/en/royalroad/icon.png'; icon = 'src/en/royalroad/icon.png';
site = 'https://www.royalroad.com/'; site = 'https://www.royalroad.com/';
parseNovels(html: string) { parseNovels(html: string) {
const novels: Plugin.NovelItem[] = []; const novels: Plugin.NovelItem[] = [];
let tempNovel = {} as Plugin.NovelItem; let tempNovel = {} as Plugin.NovelItem;
const baseUrl = this.site;
tempNovel.name = ''; tempNovel.name = '';
let isParsingNovel = false; let isParsingNovel = false;
let isNovelName = false;
const parser = new Parser({ const parser = new Parser({
onopentag(name, attribs) { onopentag(name, attribs) {
if (attribs['class']?.includes('fiction-list-item')) { if (attribs['class']?.includes('fiction-list-item')) {
@@ -25,10 +26,13 @@ class RoyalRoad implements Plugin.PluginBase {
if (isParsingNovel) { if (isParsingNovel) {
if (name === 'a' && attribs['class']?.includes('bold')) { if (name === 'a' && attribs['class']?.includes('bold')) {
tempNovel.path = attribs['href'].slice(1); tempNovel.path = attribs['href'].slice(1);
isNovelName = true;
} }
if (name === 'img') { if (name === 'img') {
tempNovel.name = attribs['alt'];
tempNovel.cover = attribs['src']; tempNovel.cover = attribs['src'];
if (tempNovel.cover && !isUrlAbsolute(tempNovel.cover)) {
tempNovel.cover = baseUrl + tempNovel.cover.slice(1);
}
} }
if (tempNovel.path && tempNovel.name) { if (tempNovel.path && tempNovel.name) {
novels.push(tempNovel); novels.push(tempNovel);
@@ -37,14 +41,8 @@ class RoyalRoad implements Plugin.PluginBase {
} }
} }
}, },
ontext(data) {
if (isNovelName) {
tempNovel.name += data;
}
},
onclosetag(name) { onclosetag(name) {
if (name === 'i') { if (name === 'h2') {
isNovelName = false;
isParsingNovel = false; isParsingNovel = false;
} }
}, },
@@ -61,25 +59,32 @@ class RoyalRoad implements Plugin.PluginBase {
showLatestNovels, showLatestNovels,
}: Plugin.PopularNovelsOptions<typeof this.filters>, }: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> { ): Promise<Plugin.NovelItem[]> {
let link = `${this.site}fictions/search`; const params = new URLSearchParams({
link += `?page=${page}`; page: page.toString(),
});
if (showLatestNovels) {
params.append('orderBy', 'last_update');
}
if (!filters) filters = this.filters || {}; if (!filters) filters = this.filters || {};
if (showLatestNovels) link += '&orderBy=last_update';
for (const key in filters) { for (const key in filters) {
if (filters[key as keyof typeof filters].value === '') continue; if (filters[key as keyof typeof filters].value === '') continue;
if (key === 'genres' || key === 'tags' || key === 'content_warnings') { if (key === 'genres' || key === 'tags' || key === 'content_warnings') {
if (filters[key].value.include) if (filters[key].value.include) {
for (const include of filters[key].value.include) { for (const include of filters[key].value.include) {
link += `&tagsAdd=${include}`; params.append('tagsAdd', include);
} }
if (filters[key].value.exclude) }
if (filters[key].value.exclude) {
for (const exclude of filters[key].value.exclude) { for (const exclude of filters[key].value.exclude) {
link += `&tagsRemove=${exclude}`; params.append('tagsRemove', exclude);
} }
} else if (typeof filters[key as keyof typeof filters] === 'object') }
link += `&${key}=${filters[key as keyof typeof filters].value}`; } 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()); const body = await fetchApi(link).then(r => r.text());
return this.parseNovels(body); return this.parseNovels(body);
@@ -94,6 +99,7 @@ class RoyalRoad implements Plugin.PluginBase {
summary: '', summary: '',
chapters: [], chapters: [],
}; };
const baseUrl = this.site;
let isNovelName = false; let isNovelName = false;
let isAuthorName = false; let isAuthorName = false;
let isDescription = false; let isDescription = false;
@@ -110,6 +116,9 @@ class RoyalRoad implements Plugin.PluginBase {
onopentag(name, attribs) { onopentag(name, attribs) {
if (name === 'img' && attribs['class']?.includes('thumbnail')) { if (name === 'img' && attribs['class']?.includes('thumbnail')) {
novel.cover = attribs['src']; novel.cover = attribs['src'];
if (novel.cover && !isUrlAbsolute(novel.cover)) {
novel.cover = baseUrl + novel.cover.slice(1);
}
} }
if (name === 'span' && attribs['class']?.includes('label-sm')) { if (name === 'span' && attribs['class']?.includes('label-sm')) {
isSpan++; isSpan++;
@@ -232,26 +241,104 @@ class RoyalRoad implements Plugin.PluginBase {
async parseChapter(chapterPath: string): Promise<string> { async parseChapter(chapterPath: string): Promise<string> {
const result = await fetchApi(this.site + chapterPath); const result = await fetchApi(this.site + chapterPath);
const html = await result.text(); const html = await result.text();
const parts: string[] = [];
const regexPatterns: RegExp[] = [
/<style>\n\s+.(.+?){[^]+?display: none;/,
/(<div class="portlet solid author-note-portlet"[^]+?)<div class="margin-bottom-20/,
/(<div class="chapter-inner chapter-content"[^]+?)<div class="portlet light t-center-/,
/(<\/div>\s+<div class="portlet solid author-note-portlet"[^]+?)<div class="row margin-bottom-10/,
];
const extractContent = (patterns: RegExp[]) => { let isChapterContent = false;
patterns.forEach(regex => { let isAuthorNote = false;
const match = html.match(regex)?.[1]; let isBeforeNote = true;
if (match) parts.push(match); let isHiddenContent = false;
}); let chapterHtml = '';
}; let notesHtml = '';
extractContent(regexPatterns); let beforeNotes = '';
const cleanup = new RegExp(`<p class="${parts[0]}.+?</p>`, 'g'); let afterNotes = '';
const chapterText = parts.slice(1).join('<hr>'); let depth = 0;
return chapterText let noteDepth = 0;
.replace(cleanup, '') let contentDepth = 0;
.replace(/<p class="[^><]+>/g, '<p>'); let hiddenDepth = 0;
const match = html.match(/<style>\n\s+.(.+?){[^.]+?display: none;/);
const hiddenClass = match?.[1]?.trim();
const parser = new Parser({
onopentag(name, attribs) {
depth++;
if (
isHiddenContent ||
(hiddenClass && attribs['class']?.includes(hiddenClass))
) {
if (!isHiddenContent) {
isHiddenContent = true;
hiddenDepth = depth;
}
return;
}
if (attribs['class']?.includes('chapter-content')) {
isChapterContent = true;
isBeforeNote = false;
contentDepth = depth;
return;
}
if (attribs['class']?.includes('author-note-portlet')) {
isAuthorNote = true;
noteDepth = depth;
notesHtml = '';
return;
}
if (isChapterContent || isAuthorNote) {
let tag = `<${name}`;
for (const attr in attribs) {
tag += ` ${attr}="${attribs[attr].replace(/"/g, '&quot;')}"`;
}
tag += '>';
if (isChapterContent) chapterHtml += tag;
if (isAuthorNote) notesHtml += tag;
}
},
ontext(text) {
if (isHiddenContent) {
return;
}
if (isChapterContent) {
chapterHtml += text;
}
if (isAuthorNote) {
notesHtml += text;
}
},
onclosetag(name) {
if (isHiddenContent) {
if (depth === hiddenDepth) {
isHiddenContent = false;
}
depth--;
return;
}
if (isChapterContent && depth === contentDepth) {
isChapterContent = false;
} else if (isAuthorNote && depth === noteDepth) {
isAuthorNote = false;
const fullNote = `<div class="author-note-${isBeforeNote ? 'before' : 'after'}">${notesHtml}</div>`;
if (isBeforeNote) {
beforeNotes += fullNote + '\n';
} else {
afterNotes += fullNote + '\n';
}
notesHtml = '';
} else if (isChapterContent || isAuthorNote) {
const closingTag = `</${name}>`;
if (isChapterContent) chapterHtml += closingTag;
if (isAuthorNote) notesHtml += closingTag;
}
depth--;
},
});
parser.write(html);
parser.end();
return [beforeNotes.trim(), chapterHtml.trim(), afterNotes.trim()]
.filter(Boolean)
.join('\n');
} }
async searchNovels( async searchNovels(