RoyalRoad: Obtain URLs properly (#1627)
* parsing state * fix inHidden gone wrong
This commit is contained in:
+270
-183
@@ -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, '"')}"`;
|
const value = attribs[attr].replace(/"/g, '"');
|
||||||
|
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(
|
||||||
|
|||||||
Reference in New Issue
Block a user