From 3cac36aa9869248f253afa0dd285e7af98ceb434 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Sat, 10 Oct 2026 21:06:21 +0800 Subject: [PATCH 1/2] fix(english/royalroad): serve chapter list through parsePage LNReader 2.1.4 loads a stored novel's chapters from its database and, when the current page has none, asks the plugin's parsePage for them. Royal Road had no parsePage, so a novel saved without its chapters failed with "Could not fetch chapters for page 1" every time it was opened. parsePage now returns the chapters from the fiction page, and the chapter JSON parsing is shared with parseNovel. Fixes #2659 --- plugins/english/royalroad.ts | 80 +++++++++++++++++++++--------------- 1 file changed, 46 insertions(+), 34 deletions(-) diff --git a/plugins/english/royalroad.ts b/plugins/english/royalroad.ts index 6fcaeda6c..62109a489 100644 --- a/plugins/english/royalroad.ts +++ b/plugins/english/royalroad.ts @@ -9,7 +9,7 @@ import { storage } from '@libs/storage'; class RoyalRoad implements Plugin.PluginBase { id = 'royalroad'; name = 'Royal Road'; - version = '2.3.1'; + version = '2.3.2'; icon = 'src/en/royalroad/icon.png'; site = 'https://www.royalroad.com/'; @@ -112,7 +112,8 @@ class RoyalRoad implements Plugin.PluginBase { path: novelPath, }; const baseUrl = this.site; - const enableVolume = this.enableVol; + const parseChapters = (scriptContent: string) => + this.parseChapters(scriptContent); let state: ParsingState = ParsingState.Idle; let statusText = ''; @@ -123,9 +124,6 @@ class RoyalRoad implements Plugin.PluginBase { const scriptContentParts: string[] = []; const genreArray: string[] = []; - let chapterJson: ChapterEntry[] = []; - let volumeJson: VolumeEntry[] = []; - const parser = new Parser({ onopentag(name, attribs) { switch (name) { @@ -245,20 +243,6 @@ class RoyalRoad implements Plugin.PluginBase { 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; } @@ -278,21 +262,7 @@ class RoyalRoad implements Plugin.PluginBase { 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, - }; - }); + novel.chapters = parseChapters(scriptContentParts.join('')); }, }); @@ -302,6 +272,48 @@ class RoyalRoad implements Plugin.PluginBase { return novel as Plugin.NovelItem; } + parseChapters(scriptContent: string): Plugin.ChapterItem[] { + const chapterMatch = scriptContent.match( + /window\.chapters\s*=\s*(\[.*?\]);/, + ); + const volumeMatch = scriptContent.match(/window\.volumes\s*=\s*(\[.*?\]);/); + const chapterJson: ChapterEntry[] = chapterMatch?.[1] + ? JSON.parse(chapterMatch[1]) + : []; + const volumeJson: VolumeEntry[] = + volumeMatch?.[1] && this.enableVol ? JSON.parse(volumeMatch[1]) : []; + + return 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, + }; + }); + } + + // LNReader asks for a page when it has no stored chapters for it, e.g. a novel + // that was saved without its chapter list. Every chapter is on the fiction page. + async parsePage(novelPath: string, page: string): Promise { + const result = await fetchApi(this.site + novelPath); + const chapters = this.parseChapters(await result.text()); + const pageChapters = chapters.filter( + chapter => (chapter.page || '1') === page, + ); + // The app shows a single volume as page "1", so return the whole list then. + return { + chapters: pageChapters.length || page !== '1' ? pageChapters : chapters, + }; + } + async parseChapter(chapterPath: string): Promise { const result = await fetchApi(this.site + chapterPath); const html = await result.text(); From 02a493d92769d3ce9f15ee9fc711c86352066beb Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Sat, 10 Oct 2026 21:36:41 +0800 Subject: [PATCH 2/2] fix(english/royalroad): return the whole chapter list for page 1 LNReader asks parsePage for page "1" when a stored novel has no chapter pages at all. With volume view on, a novel that mixes chapters outside any volume with volume chapters (e.g. Pale Lights, 1 + 196) got only the one unvolumed chapter. Page "1" now returns every chapter; any other page returns that volume's chapters. --- plugins/english/royalroad.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/plugins/english/royalroad.ts b/plugins/english/royalroad.ts index 62109a489..61ed3c780 100644 --- a/plugins/english/royalroad.ts +++ b/plugins/english/royalroad.ts @@ -305,12 +305,13 @@ class RoyalRoad implements Plugin.PluginBase { async parsePage(novelPath: string, page: string): Promise { const result = await fetchApi(this.site + novelPath); const chapters = this.parseChapters(await result.text()); - const pageChapters = chapters.filter( - chapter => (chapter.page || '1') === page, - ); - // The app shows a single volume as page "1", so return the whole list then. + // Page "1" is asked for when the app has no stored pages at all, so give it + // the whole list; any other page is a volume title. return { - chapters: pageChapters.length || page !== '1' ? pageChapters : chapters, + chapters: + page === '1' + ? chapters + : chapters.filter(chapter => chapter.page === page), }; }