From 765e9b326f3a1cc164a24e93bdc2441d77c91b67 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Sun, 18 Oct 2026 09:01:18 +0800 Subject: [PATCH 1/9] fix(english/fenrirrealm): handle verification-gated chapters Fenrir Realm now gates free chapters behind a Cloudflare Turnstile check and the chapter API returns empty content with content_format "locked" until the reader has a verified session, so the plugin silently returned empty chapters. - When a chapter is locked, mint a read token with the existing (WebView verified) session and refetch it, as the site's own reader does. - Otherwise throw a clear error asking to complete the check in WebView instead of returning an empty chapter, carrying the read-token HTTP status (403 without a session); also throw on non-2xx chapter responses. - Volume chapter slugs now include the volume, so resolveUrl collapses the repeated volume segment; the WebView URL no longer 404s. --- plugins/english/fenrirrealm.ts | 85 ++++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 5 deletions(-) diff --git a/plugins/english/fenrirrealm.ts b/plugins/english/fenrirrealm.ts index a758f4db4..d496dac8c 100644 --- a/plugins/english/fenrirrealm.ts +++ b/plugins/english/fenrirrealm.ts @@ -31,6 +31,21 @@ type APIChapter = { created_at: string; }; +type APIChapterContent = { + id: number; + uuid: string | null; + content: string | null; + content_format?: string; + remint_chapter_id: number | null; +}; + +type ReadTokenResult = { + token?: string; + status?: number; +}; + +type HttpError = Error & { status?: number }; + type ChapterInfo = { name: string; path: string; @@ -75,7 +90,7 @@ class FenrirRealmPlugin implements Plugin.PluginBase { name = 'Fenrir Realm'; icon = 'src/en/fenrirrealm/icon.png'; site = 'https://fenrirealm.com'; - version = '1.1.3'; + version = '1.1.4'; imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined; hideLocked = storage.get('hideLocked'); @@ -218,8 +233,27 @@ class FenrirRealmPlugin implements Plugin.PluginBase { const chapterId = chapterPath.split('~~')[1]; if (chapterId) { const url = `${this.site}/api/new/v2/chapters/${chapterId}`; - const res = await fetchApi(url); - const json = await res.json(); + let json = await this.fetchChapterContent(url); + + if (json.content_format === 'locked') { + // Free chapters are gated behind a Cloudflare Turnstile check. Once it + // has been passed in WebView, the session can mint a read token for + // this chapter the same way the site's own reader does. + const readToken = await this.mintReadToken(json); + if (readToken.token) { + json = await this.fetchChapterContent(url, readToken.token); + } + if (json.content_format === 'locked') { + const error: HttpError = new Error( + 'Fenrir Realm requires a human verification check to read this chapter. Open it in WebView, complete the check, then try again.', + ); + // Keep the read-token status (403 without a verified session) so + // callers can tell an anti-bot refusal from a parsing failure. + error.status = readToken.status; + throw error; + } + } + const content = json.content; if (content) { @@ -303,7 +337,7 @@ class FenrirRealmPlugin implements Plugin.PluginBase { } // Fallback or old method - const url = `${this.site}/series/${chapterPath.split('~~')[0]}`; + const url = this.resolveUrl(chapterPath); const result = await fetchApi(url); const body = await result.text(); @@ -380,6 +414,42 @@ class FenrirRealmPlugin implements Plugin.PluginBase { return chapterText; } + async fetchChapterContent( + url: string, + readToken?: string, + ): Promise { + const res = await fetchApi(url, { + headers: readToken ? { Authorization: `Bearer ${readToken}` } : {}, + }); + if (!res.ok) { + const error: HttpError = new Error( + `Failed to load chapter (HTTP ${res.status}). Open it in WebView and try again.`, + ); + error.status = res.status; + throw error; + } + return res.json(); + } + + async mintReadToken(chapter: APIChapterContent): Promise { + const chapterId = chapter.remint_chapter_id || chapter.uuid || chapter.id; + try { + const res = await fetchApi(`${this.site}/api/read-token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ chapter_id: String(chapterId) }), + }); + if (!res.ok) return { status: res.status }; + const json = await res.json(); + return json.active && json.token ? { token: json.token } : {}; + } catch { + return {}; + } + } + async searchNovels( searchTerm: string, pageNo: number, @@ -417,7 +487,12 @@ class FenrirRealmPlugin implements Plugin.PluginBase { } resolveUrl(path: string): string { - return `${this.site}/series/${path.split('~~')[0]}`; + // Volume chapter slugs now include the volume (e.g. "vol1/1"), so paths + // built as novel/vol1/vol1/1 must drop the repeated volume segment. + const chapterPath = path + .split('~~')[0] + .replace(/^([^/]+)\/([^/]+)\/\2\//, '$1/$2/'); + return `${this.site}/series/${chapterPath}`; } filters = { From b5705fbe2cd68b99235364778fe6e98f18550315 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Sun, 18 Oct 2026 09:04:47 +0800 Subject: [PATCH 2/9] no-mistakes(review): fix(english/fenrirrealm): fall back to HTML on non-blocking API errors --- plugins/english/fenrirrealm.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/plugins/english/fenrirrealm.ts b/plugins/english/fenrirrealm.ts index d496dac8c..341a29c36 100644 --- a/plugins/english/fenrirrealm.ts +++ b/plugins/english/fenrirrealm.ts @@ -422,11 +422,14 @@ class FenrirRealmPlugin implements Plugin.PluginBase { headers: readToken ? { Authorization: `Bearer ${readToken}` } : {}, }); if (!res.ok) { - const error: HttpError = new Error( - `Failed to load chapter (HTTP ${res.status}). Open it in WebView and try again.`, - ); - error.status = res.status; - throw error; + if ([401, 403, 429, 503].includes(res.status)) { + const error: HttpError = new Error( + `Failed to load chapter (HTTP ${res.status}). Open it in WebView and try again.`, + ); + error.status = res.status; + throw error; + } + return { id: 0, uuid: null, content: null, remint_chapter_id: null }; } return res.json(); } From ce1d001c1886d3dc3ef4d1db5415907f6d0642b1 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Sun, 18 Oct 2026 09:13:33 +0800 Subject: [PATCH 3/9] no-mistakes(ci): The CI failure was in `popularNovels`, not `parseChapter`. On the runner, the series list request came back as a non-2xx block page, and the code replaced it with a generic error that had no status. The live check couldn't classify that, so it reported FAIL. I changed `popularNovels` in `plugins/english/fenrirrealm.ts` so that a non-OK response now throws the same user-facing "open it in WebView" message with `error.status` set to the real HTTP status. A JSON parse failure on a 2xx response still throws the same message with no status, so a genuine parsing problem still reports as a failure. I didn't edit `scripts/live-check-plugin.js` or the workflow. The `parseChapter` paths already carried real statuses. The read-token 403 and the chapter API's 401/403/429/503 responses both set `error.status`. In the earlier run `parseChapter` never ran, because the check stopped at `popularNovels`. Prettier and ESLint pass. A local `node scripts/live-check-plugin.js plugins/english/fenrirrealm.ts` gave PASS for `popularNovels`, `searchNovels` and `parseNovel`, and INCONCLUSIVE for `parseChapter` with HTTP 403. Locally the list request wasn't blocked, so I couldn't reproduce the runner's `popularNovels` failure. I haven't confirmed that the runner's response was a 403 or 503. If it was a different non-2xx status, the check will still report FAIL. I made no version change here; the file is at 1.1.4 --- plugins/english/fenrirrealm.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/plugins/english/fenrirrealm.ts b/plugins/english/fenrirrealm.ts index 341a29c36..64644478b 100644 --- a/plugins/english/fenrirrealm.ts +++ b/plugins/english/fenrirrealm.ts @@ -124,13 +124,21 @@ class FenrirRealmPlugin implements Plugin.PluginBase { const res = await fetchApi( `${this.site}/api/new/v2/series?${params.toString()}`, - ).then(r => - r.json().catch(() => { - throw new Error( + ).then(r => { + const failure = () => { + const error: HttpError = new Error( 'There was an error fetching the data from the server. Please try to open it in WebView', ); - }), - ); + // Keep the real HTTP status so an anti-bot refusal is distinguishable + // from a parsing failure. + if (!r.ok) error.status = r.status; + return error; + }; + if (!r.ok) throw failure(); + return r.json().catch(() => { + throw failure(); + }); + }); return (res.data || []).map((r: APINovel) => this.parseNovelFromApi(r)); } From e0f3c3973440b51d74a3343afe6dd21be712a40e Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Sun, 18 Oct 2026 09:20:24 +0800 Subject: [PATCH 4/9] fix(english/fenrirrealm): use corrected chapter path for SvelteKit fallback The __data.json fallback still built its URL from the raw chapter path, so volume chapters requested novel/vol1/vol1/1. Build it from resolveUrl so it hits the same chapter page as the HTML fallback. --- plugins/english/fenrirrealm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/english/fenrirrealm.ts b/plugins/english/fenrirrealm.ts index 64644478b..9c7438c66 100644 --- a/plugins/english/fenrirrealm.ts +++ b/plugins/english/fenrirrealm.ts @@ -373,7 +373,7 @@ class FenrirRealmPlugin implements Plugin.PluginBase { // Fallback to SvelteKit JSON if HTML parsing fails or is empty try { - const jsonUrl = `${this.site}/series/${chapterPath.split('~~')[0]}/__data.json?x-sveltekit-invalidated=001`; + const jsonUrl = `${this.resolveUrl(chapterPath)}/__data.json?x-sveltekit-invalidated=001`; const jsonRes = await fetchApi(jsonUrl); const json: Nodes = await jsonRes.json(); From 468542b3d5457da4ec2ebd772c56a5137e8d5d4d Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Tue, 27 Oct 2026 11:59:06 +0800 Subject: [PATCH 5/9] fix(english/fenrirrealm): drop duplicated volume segment in chapter fallback URLs --- plugins/english/fenrirrealm.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/plugins/english/fenrirrealm.ts b/plugins/english/fenrirrealm.ts index 2e8eb76d3..8878ed6b1 100644 --- a/plugins/english/fenrirrealm.ts +++ b/plugins/english/fenrirrealm.ts @@ -298,7 +298,7 @@ class FenrirRealmPlugin implements Plugin.PluginBase { name = 'Fenrir Realm'; icon = 'src/en/fenrirrealm/icon.png'; site = 'https://fenrirealm.com'; - version = '1.1.4'; + version = '1.1.5'; imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined; private cachedReadToken: CachedReadToken | null = null; @@ -841,9 +841,13 @@ class FenrirRealmPlugin implements Plugin.PluginBase { if (json.content_format === 'locked') { this.cachedReadToken = null; - throw new Error( + const error: Error & { status?: number } = new Error( 'Chapter is protected by Cloudflare Turnstile. Please open this chapter in WebView to verify and try again.', ); + // Keep an HTTP status so callers can tell an anti-bot refusal from a + // parsing failure. + error.status = 403; + throw error; } if (typeof json.content === 'string' && json.content) { @@ -852,7 +856,7 @@ class FenrirRealmPlugin implements Plugin.PluginBase { } // Fallback or old method - const url = `${this.site}/series/${slugPath}`; + const url = this.resolveUrl(chapterPath); const result = await fetchApi(url); const body = await result.text(); @@ -881,7 +885,7 @@ class FenrirRealmPlugin implements Plugin.PluginBase { // Fallback to SvelteKit JSON if HTML parsing fails or is empty try { - const jsonUrl = `${this.site}/series/${slugPath}/__data.json?x-sveltekit-invalidated=001`; + const jsonUrl = `${this.resolveUrl(chapterPath)}/__data.json?x-sveltekit-invalidated=001`; const jsonRes = await fetchApi(jsonUrl); const json: Nodes = await jsonRes.json(); @@ -940,7 +944,12 @@ class FenrirRealmPlugin implements Plugin.PluginBase { } resolveUrl(path: string): string { - return `${this.site}/series/${path.split('~~')[0]}`; + // Volume chapter slugs already include the volume (e.g. "vol1/1"), so a + // path built as novel/vol1/vol1/1 must drop the repeated volume segment. + const chapterPath = path + .split('~~')[0] + .replace(/^([^/]+)\/([^/]+)\/\2\//, '$1/$2/'); + return `${this.site}/series/${chapterPath}`; } filters = { From 271e75e02c244b2c1a749cc751c31d76d4c6d1e8 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Tue, 27 Oct 2026 11:59:24 +0800 Subject: [PATCH 6/9] fix(english/fenrirrealm): report HTTP status when the series list is blocked --- plugins/english/fenrirrealm.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/plugins/english/fenrirrealm.ts b/plugins/english/fenrirrealm.ts index 8878ed6b1..88b38a68f 100644 --- a/plugins/english/fenrirrealm.ts +++ b/plugins/english/fenrirrealm.ts @@ -623,13 +623,20 @@ class FenrirRealmPlugin implements Plugin.PluginBase { const res = await fetchApi( `${this.site}/api/new/v2/series?${params.toString()}`, - ).then(r => - r.json().catch(() => { - throw new Error( - 'There was an error fetching the data from the server. Please try to open it in WebView', - ); - }), - ); + ).then(async r => { + const error: Error & { status?: number } = new Error( + 'There was an error fetching the data from the server. Please try to open it in WebView', + ); + // Keep the real HTTP status so a block page is distinguishable from a + // parsing failure. + if (!r.ok) { + error.status = r.status; + throw error; + } + return r.json().catch(() => { + throw error; + }); + }); return (res.data || []).map((r: APINovel) => this.parseNovelFromApi(r)); } From b14b0e98f827067c54b1251704729568fb644a6a Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Tue, 27 Oct 2026 12:02:04 +0800 Subject: [PATCH 7/9] no-mistakes(review): carry real HTTP status on fenrir verify errors --- plugins/english/fenrirrealm.ts | 35 ++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/plugins/english/fenrirrealm.ts b/plugins/english/fenrirrealm.ts index 88b38a68f..6a36628c6 100644 --- a/plugins/english/fenrirrealm.ts +++ b/plugins/english/fenrirrealm.ts @@ -302,6 +302,7 @@ class FenrirRealmPlugin implements Plugin.PluginBase { imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined; private cachedReadToken: CachedReadToken | null = null; + private lastMintRefusal: Response | null = null; hideLocked = storage.get('hideLocked'); pluginSettings = { @@ -405,6 +406,7 @@ class FenrirRealmPlugin implements Plugin.PluginBase { chapterTarget?: string | number | null, cid?: number | null, ): Promise { + this.lastMintRefusal = null; try { const payload: Record = {}; if ( @@ -427,7 +429,10 @@ class FenrirRealmPlugin implements Plugin.PluginBase { headers, body: JSON.stringify(payload), }); - if (!res.ok) return null; + if (!res.ok) { + this.lastMintRefusal = res; + return null; + } const data = await res.json(); return data?.active && typeof data.token === 'string' && @@ -459,6 +464,17 @@ class FenrirRealmPlugin implements Plugin.PluginBase { } } + private verifyInWebViewError(refusal?: Response | null): Error { + const error: Error & { status?: number; response?: Response } = new Error( + 'Chapter is protected by Cloudflare Turnstile. Please open this chapter in WebView to verify and try again.', + ); + if (refusal && !refusal.ok) { + error.status = refusal.status; + error.response = refusal; + } + return error; + } + private async getReadToken( chapterId?: number | null, ): Promise { @@ -768,6 +784,15 @@ class FenrirRealmPlugin implements Plugin.PluginBase { } this.captureReadTokenHeader(res, initialCid); } + if (!res.ok && res.status === 403) { + throw this.verifyInWebViewError( + this.lastMintRefusal?.status === 403 ? this.lastMintRefusal : res, + ); + } + } + + if (res.status === 401 || res.status === 503) { + throw this.verifyInWebViewError(res); } if (res.ok) { @@ -848,13 +873,7 @@ class FenrirRealmPlugin implements Plugin.PluginBase { if (json.content_format === 'locked') { this.cachedReadToken = null; - const error: Error & { status?: number } = new Error( - 'Chapter is protected by Cloudflare Turnstile. Please open this chapter in WebView to verify and try again.', - ); - // Keep an HTTP status so callers can tell an anti-bot refusal from a - // parsing failure. - error.status = 403; - throw error; + throw this.verifyInWebViewError(this.lastMintRefusal); } if (typeof json.content === 'string' && json.content) { From ef63649db00c284d5d642d4853db0b315b76bb4e Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Tue, 27 Oct 2026 12:02:50 +0800 Subject: [PATCH 8/9] no-mistakes(review): clear stale mint refusal after successful mint --- plugins/english/fenrirrealm.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/english/fenrirrealm.ts b/plugins/english/fenrirrealm.ts index 6a36628c6..1e1dcdd9d 100644 --- a/plugins/english/fenrirrealm.ts +++ b/plugins/english/fenrirrealm.ts @@ -448,6 +448,7 @@ class FenrirRealmPlugin implements Plugin.PluginBase { data = await requestMint(null); } if (data) { + this.lastMintRefusal = null; const numericId = typeof cid === 'number' && cid > 0 ? cid @@ -753,6 +754,7 @@ class FenrirRealmPlugin implements Plugin.PluginBase { async parseChapter(chapterPath: string): Promise { const [slugPath, chapterId] = chapterPath.split('~~'); + this.lastMintRefusal = null; const apiUrl = chapterId ? `${this.site}/api/new/v2/chapters/${chapterId}` : `${this.site}/api/new/v2/series/${slugPath}`; From c7c25ce7b45825ea4009099e2f728f8befda9740 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Tue, 27 Oct 2026 12:13:47 +0800 Subject: [PATCH 9/9] no-mistakes(ci): I created the empty commit e1709ed ("ci: re-run checks after base refresh") as instructed, with no file changes. I did not push it or run any checks; the push is for the outer executor, and it should trigger a fresh pull_request run. From the CI log and the earlier diff, the only file this PR changes is plugins/english/fenrirrealm.ts. The log shows it as INCONCLUSIVE (HTTP 403, likely anti-bot block), not FAIL. The FAIL rows are all in plugins this PR doesn't touch: hexnovels, seanovel, novelhi, royalroad, ficbook, zelluloza and topliba.broken. They come from the live sites or runner blocking, so a fresh run on the refreshed file list should only exercise fenrirrealm