RoyalRoad: Obtain URLs properly (#1627)

* parsing state

* fix inHidden gone wrong
This commit is contained in:
K1ngfish3r
2025-04-11 08:16:46 +05:00
committed by GitHub
parent a5d181b612
commit e728e66dbe
+270 -183
View File
@@ -8,15 +8,14 @@ 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.2.0'; version = '2.2.1';
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 baseUrl = this.site;
const novels: Plugin.NovelItem[] = []; const novels: Plugin.NovelItem[] = [];
let tempNovel = {} as Plugin.NovelItem; let tempNovel = {} as Plugin.NovelItem;
const baseUrl = this.site;
tempNovel.name = '';
let isParsingNovel = false; let isParsingNovel = false;
const parser = new Parser({ const parser = new Parser({
onopentag(name, attribs) { onopentag(name, attribs) {
@@ -24,8 +23,8 @@ class RoyalRoad implements Plugin.PluginBase {
isParsingNovel = true; isParsingNovel = true;
} }
if (isParsingNovel) { if (isParsingNovel) {
if (name === 'a' && attribs['class']?.includes('bold')) { if (name === 'a') {
tempNovel.path = attribs['href'].slice(1); tempNovel.path = attribs['href'].split('/').slice(1, 3).join('/');
} }
if (name === 'img') { if (name === 'img') {
tempNovel.name = attribs['alt']; tempNovel.name = attribs['alt'];
@@ -37,7 +36,6 @@ class RoyalRoad implements Plugin.PluginBase {
if (tempNovel.path && tempNovel.name) { if (tempNovel.path && tempNovel.name) {
novels.push(tempNovel); novels.push(tempNovel);
tempNovel = {} as Plugin.NovelItem; tempNovel = {} as Plugin.NovelItem;
tempNovel.name = '';
} }
} }
}, },
@@ -96,145 +94,192 @@ class RoyalRoad implements Plugin.PluginBase {
const novel: Plugin.SourceNovel = { const novel: Plugin.SourceNovel = {
path: novelPath, path: novelPath,
name: '', name: '',
summary: '', author: '',
chapters: [], chapters: [],
}; };
const baseUrl = this.site; const baseUrl = this.site;
let isNovelName = false;
let isAuthorName = false; enum ParsingState {
let isDescription = false; Idle,
let isH4 = false; InTitle,
let isSpan = 0; InAuthor,
let isTags = false; InDescription,
let isGenres = false; InTags,
InTagLink,
InStatusSpan,
InScript,
}
let state: ParsingState = ParsingState.Idle;
let scriptContent = '';
let statusText = '';
let statusSpanCounter = 0;
const genreArray: string[] = []; const genreArray: string[] = [];
let isFooter = false;
let isScript = false;
let chapterJson: ChapterEntry[] = []; let chapterJson: ChapterEntry[] = [];
let volumeJson: VolumeEntry[] = []; let volumeJson: VolumeEntry[] = [];
const parser = new Parser({ const parser = new Parser({
onopentag(name, attribs) { onopentag(name, attribs) {
if (name === 'img' && attribs['class']?.includes('thumbnail')) { switch (name) {
novel.cover = attribs['src']; case 'h1':
if (novel.cover && !isUrlAbsolute(novel.cover)) { state = ParsingState.InTitle;
novel.cover = baseUrl + novel.cover.slice(1); break;
} case 'a':
} if (
if (name === 'span' && attribs['class']?.includes('label-sm')) { attribs['href']?.startsWith('/profile/') &&
isSpan++; novel.author === ''
} ) {
if (name === 'span' && attribs['class']?.includes('tags')) { state = ParsingState.InAuthor;
isTags = true; } else if (state === ParsingState.InTags) {
state = ParsingState.InTagLink;
}
break;
case 'div':
if (attribs['class'] === 'description') {
state = ParsingState.InDescription;
novel.summary = '';
}
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;
scriptContent = '';
break;
} }
}, },
onopentagname(name) { ontext(text) {
if (name === 'h1') { const trimmedText = text.trim();
isNovelName = true; if (!trimmedText && state !== ParsingState.InScript) return;
}
if (isH4 && name === 'a') { switch (state) {
isAuthorName = true; case ParsingState.InTitle:
} novel.name += text;
if (isTags && name === 'a') { break;
isGenres = true; case ParsingState.InAuthor:
} novel.author = trimmedText;
if (name === 'label') { break;
isDescription = false; case ParsingState.InDescription:
isTags = false; novel.summary += text;
} break;
if (isFooter && name === 'script') { case ParsingState.InStatusSpan:
isScript = true; statusText = trimmedText;
} break;
}, case ParsingState.InTagLink:
onattribute(name, value) { genreArray.push(trimmedText);
if (name === 'class' && value === 'description') { break;
isDescription = true; case ParsingState.InScript:
} scriptContent += text;
if (name === 'class' && value === 'page-footer footer') { break;
isFooter = true;
}
},
ontext(data) {
if (isNovelName) {
novel.name = 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) { onclosetag(name) {
if (name === 'h1') { switch (name) {
isNovelName = false; case 'h1':
isH4 = true; if (state === ParsingState.InTitle) {
novel.name = novel.name.trim();
state = ParsingState.Idle;
}
break;
case 'a':
if (state === ParsingState.InTagLink) {
state = ParsingState.InTags;
} else if (state === ParsingState.InAuthor) {
state = ParsingState.Idle;
}
break;
case 'div':
if (state === ParsingState.InDescription) {
if (novel.summary) {
novel.summary = novel.summary.trim();
}
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 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]) {
volumeJson = JSON.parse(volumeMatch[1]);
}
}
break;
} }
if (name === 'h4') { },
isH4 = false; onend() {
} switch (statusText) {
if (name === 'a') { case 'ONGOING':
isGenres = false; novel.status = NovelStatus.Ongoing;
} break;
if (name === 'script') { case 'HIATUS':
isScript = false; novel.status = NovelStatus.OnHiatus;
} break;
if (name === 'body') { case 'COMPLETED':
isFooter = false; 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,
};
});
},
onerror(error) {
console.error('HTML Parser Error:', error);
}, },
}); });
parser.write(html); parser.write(html);
parser.end(); 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; return novel;
} }
@@ -242,103 +287,145 @@ class RoyalRoad implements Plugin.PluginBase {
const result = await fetchApi(this.site + chapterPath); const result = await fetchApi(this.site + chapterPath);
const html = await result.text(); const html = await result.text();
let isChapterContent = false; enum ChapterParsingState {
let isAuthorNote = false; Idle,
let isBeforeNote = true; InNote,
let isHiddenContent = false; InChapter,
InHidden,
}
let state = ChapterParsingState.Idle;
let stateDepth = 0;
let depth = 0;
let chapterHtml = ''; let chapterHtml = '';
let notesHtml = ''; let notesHtml = '';
let beforeNotes = ''; let beforeNotes = '';
let afterNotes = ''; let afterNotes = '';
let depth = 0; let isBeforeChapter = true;
let noteDepth = 0;
let contentDepth = 0;
let hiddenDepth = 0;
const match = html.match(/<style>\n\s+.(.+?){[^.]+?display: none;/); const match = html.match(/<style>\n\s+.(.+?){[^.]+?display: none;/);
const hiddenClass = match?.[1]?.trim(); const hiddenClass = match?.[1]?.trim();
let stateBeforeHidden: {
state: ChapterParsingState;
depth: number;
} | null = null;
const parser = new Parser({ const parser = new Parser({
onopentag(name, attribs) { onopentag(name, attribs) {
depth++; depth++;
const classes = attribs['class'] || '';
if ( if (
isHiddenContent || state !== ChapterParsingState.InHidden &&
(hiddenClass && attribs['class']?.includes(hiddenClass)) hiddenClass &&
classes.includes(hiddenClass)
) { ) {
if (!isHiddenContent) { stateBeforeHidden = { state: state, depth: stateDepth };
isHiddenContent = true; state = ChapterParsingState.InHidden;
hiddenDepth = depth; stateDepth = depth;
}
return; return;
} }
if (attribs['class']?.includes('chapter-content')) { switch (state) {
isChapterContent = true; case ChapterParsingState.Idle:
isBeforeNote = false; if (classes.includes('chapter-content')) {
contentDepth = depth; state = ChapterParsingState.InChapter;
return; stateDepth = depth;
} isBeforeChapter = false;
if (attribs['class']?.includes('author-note-portlet')) { } else if (classes.includes('author-note-portlet')) {
isAuthorNote = true; state = ChapterParsingState.InNote;
noteDepth = depth; stateDepth = depth;
notesHtml = ''; notesHtml = '';
return; }
break;
case ChapterParsingState.InHidden:
return;
} }
if (isChapterContent || isAuthorNote) { if (
state === ChapterParsingState.InChapter ||
state === ChapterParsingState.InNote
) {
let tag = `<${name}`; let tag = `<${name}`;
for (const attr in attribs) { for (const attr in attribs) {
tag += ` ${attr}="${attribs[attr].replace(/"/g, '&quot;')}"`; const value = attribs[attr].replace(/"/g, '&quot;');
tag += ` ${attr}="${value}"`;
} }
tag += '>'; tag += '>';
if (isChapterContent) chapterHtml += tag;
if (isAuthorNote) notesHtml += tag; if (state === ChapterParsingState.InChapter) {
chapterHtml += tag;
} else {
notesHtml += tag;
}
} }
}, },
ontext(text) { ontext(text) {
if (isHiddenContent) { switch (state) {
return; case ChapterParsingState.InChapter:
} chapterHtml += text;
if (isChapterContent) { break;
chapterHtml += text; case ChapterParsingState.InNote:
} notesHtml += text;
if (isAuthorNote) { break;
notesHtml += text;
} }
}, },
onclosetag(name) { onclosetag(name) {
if (isHiddenContent) { if (depth === stateDepth) {
if (depth === hiddenDepth) { switch (state) {
isHiddenContent = false; case ChapterParsingState.InHidden:
if (!stateBeforeHidden) {
state = ChapterParsingState.Idle; // Attempt recovery
stateDepth = 0;
} else {
state = stateBeforeHidden.state;
stateDepth = stateBeforeHidden.depth;
stateBeforeHidden = null;
}
depth--;
return;
case ChapterParsingState.InChapter:
state = ChapterParsingState.Idle;
stateDepth = 0;
depth--;
return;
case ChapterParsingState.InNote:
const noteClass = `author-note-${isBeforeChapter ? 'before' : 'after'}`;
const fullNote = `<div class="${noteClass}">${notesHtml.trim()}</div>`;
if (isBeforeChapter) {
beforeNotes += fullNote;
} else {
afterNotes += fullNote;
}
notesHtml = '';
state = ChapterParsingState.Idle;
stateDepth = 0;
depth--;
return;
} }
depth--;
return;
} }
if (isChapterContent && depth === contentDepth) { if (
isChapterContent = false; state === ChapterParsingState.InChapter ||
} else if (isAuthorNote && depth === noteDepth) { state === ChapterParsingState.InNote
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}>`; const closingTag = `</${name}>`;
if (isChapterContent) chapterHtml += closingTag; if (state === ChapterParsingState.InChapter) {
if (isAuthorNote) notesHtml += closingTag; chapterHtml += closingTag;
} else {
notesHtml += closingTag;
}
} }
depth--; depth--;
}, },
}); });
parser.write(html); parser.write(html);
parser.end(); parser.end();
return [beforeNotes.trim(), chapterHtml.trim(), afterNotes.trim()]
return [beforeNotes, chapterHtml.trim(), afterNotes]
.filter(Boolean) .filter(Boolean)
.join('\n'); .join('\n<hr class="notes-separator">\n');
} }
async searchNovels( async searchNovels(