From 56cd88d8a1fb8afdff866e9081e825e1f8741fa5 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 8 Oct 2026 09:25:34 +0800 Subject: [PATCH 1/5] fix(english/wtrlab): report Cloudflare challenge instead of parse errors WTR-LAB now answers every request (pages, _next/data, /api) with a Cloudflare managed challenge (HTTP 403, cf-mitigated: challenge) until it is solved in WebView. The plugin parsed that "Just a moment..." page as site markup, so browse/search threw "Could not find __NEXT_DATA__ on novel finder page", Latest threw a JSON syntax error and parseNovel returned an empty novel, hiding the real remedy. Route the page/data fetches that read __NEXT_DATA__ (and the Latest feed) through a fetchSite helper that throws a WebView hint on 403/503. Once the challenge is cleared, the existing parsing works unchanged. --- plugins/english/wtrlab.ts | 49 +++++++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/plugins/english/wtrlab.ts b/plugins/english/wtrlab.ts index 3b9f7dd31..188f5c486 100644 --- a/plugins/english/wtrlab.ts +++ b/plugins/english/wtrlab.ts @@ -1,5 +1,5 @@ import { Plugin } from '@/types/plugin'; -import { fetchApi } from '@libs/fetch'; +import { FetchInit, fetchApi } from '@libs/fetch'; import { FilterTypes, Filters } from '@libs/filterInputs'; import { CheerioAPI, load as parseHTML } from 'cheerio'; import { gcm } from '@libs/aes'; @@ -53,7 +53,7 @@ class WTRLAB implements Plugin.PluginBase { id = 'WTRLAB'; name = 'WTR-LAB'; site = 'https://wtr-lab.com/'; - version = '1.2.5'; + version = '1.2.6'; icon = 'src/en/wtrlab/icon.png'; sourceLang = 'en/'; baggage = ''; @@ -106,6 +106,24 @@ class WTRLAB implements Plugin.PluginBase { return (storage.get('sessionCookie') || '').trim(); } + /** + * Fetch a wtr-lab page or JSON route, refusing a Cloudflare challenge. + * + * The whole site sits behind a managed challenge that answers 403 with a + * "Just a moment..." page until it is solved in WebView. Parsing that page + * as wtr-lab markup used to surface as "Could not find __NEXT_DATA__" or a + * JSON syntax error, which hid the actual fix from the user. + */ + async fetchSite(url: string, init?: FetchInit): Promise { + const res = await fetchApi(url, init); + if (res.status === 403 || res.status === 503) { + throw new Error( + `Cloudflare protection detected (HTTP ${res.status}). Please open the plugin in WebView to solve the challenge, then try again.`, + ); + } + return res; + } + /** * Resolve the chapter payload. * @@ -348,7 +366,7 @@ class WTRLAB implements Plugin.PluginBase { } if (showLatestNovels) { - const response = await fetchApi(this.site + 'api/home/recent', { + const response = await this.fetchSite(this.site + 'api/home/recent', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -374,9 +392,9 @@ class WTRLAB implements Plugin.PluginBase { return novels; } else { - const finderPage = await fetchApi(this.site + 'en/novel-finder').then( - res => res.text(), - ); + const finderPage = await this.fetchSite( + this.site + 'en/novel-finder', + ).then(res => res.text()); const finderCheerio = parseHTML(finderPage); const nextData = finderCheerio('#__NEXT_DATA__').html(); if (!nextData) { @@ -386,7 +404,7 @@ class WTRLAB implements Plugin.PluginBase { link = `${this.site}_next/data/${buildId}/en/novel-finder.json?${params.toString()}`; - const response = await fetchApi(link); + const response = await this.fetchSite(link); const json = await response.json(); const seenIds = new Set(); @@ -419,7 +437,9 @@ class WTRLAB implements Plugin.PluginBase { } async parseNovel(novelPath: string): Promise { - const body = await fetchApi(this.site + novelPath).then(res => res.text()); + const body = await this.fetchSite(this.site + novelPath).then(res => + res.text(), + ); const loadedCheerio = parseHTML(body); const baggage = loadedCheerio('meta[name="baggage"]').attr('content'); @@ -479,8 +499,8 @@ class WTRLAB implements Plugin.PluginBase { if (!novel.name) { novel.name = resolveTokens( loadedCheerio('h1.text-uppercase').text() || - loadedCheerio('h1.long-title').text() || - loadedCheerio('.title-wrap h1').text().trim(), + loadedCheerio('h1.long-title').text() || + loadedCheerio('.title-wrap h1').text().trim(), ); } @@ -493,8 +513,8 @@ class WTRLAB implements Plugin.PluginBase { if (!novel.summary) { novel.summary = resolveTokens( loadedCheerio('.description').text().trim() || - loadedCheerio('.desc-wrap .description').text().trim() || - loadedCheerio('.lead').text().trim(), + loadedCheerio('.desc-wrap .description').text().trim() || + loadedCheerio('.lead').text().trim(), ); } @@ -526,8 +546,7 @@ class WTRLAB implements Plugin.PluginBase { if (Array.isArray(pageProps?.tags)) { for (const tag of pageProps.tags) { - const title = - tag?.title && resolveTokens(String(tag.title).trim()); + const title = tag?.title && resolveTokens(String(tag.title).trim()); if (title) labels.push(title); } } @@ -753,7 +772,7 @@ class WTRLAB implements Plugin.PluginBase { } if (!rawId || !chapterNo) { - const body = await fetchApi(url).then(res => res.text()); + const body = await this.fetchSite(url).then(res => res.text()); loadedCheerio = parseHTML(body); const chapterJson = loadedCheerio('#__NEXT_DATA__').html() + ''; From 0e152268c617d22fada5be5b55ae638749ffab5d Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 8 Oct 2026 09:33:53 +0800 Subject: [PATCH 2/5] no-mistakes(review): guard wtrlab sibling fetches against Cloudflare challenge --- plugins/english/wtrlab.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/plugins/english/wtrlab.ts b/plugins/english/wtrlab.ts index 188f5c486..4a7a0f36d 100644 --- a/plugins/english/wtrlab.ts +++ b/plugins/english/wtrlab.ts @@ -427,7 +427,7 @@ class WTRLAB implements Plugin.PluginBase { } async fetchTokens() { - const body = await fetchApi(this.site + this.sourceLang).then(res => + const body = await this.fetchSite(this.site + this.sourceLang).then(res => res.text(), ); const $ = parseHTML(body); @@ -658,7 +658,7 @@ class WTRLAB implements Plugin.PluginBase { combined = new Uint8Array(ciphertext.length + tag.length); // Make the ciphertext + tag format expected for decryption - combined.set(ciphertext), combined.set(tag, ciphertext.length); + (combined.set(ciphertext), combined.set(tag, ciphertext.length)); // Decrypt with encKey // Convert the key to bytes (first 32 characters of encKey) @@ -717,7 +717,7 @@ class WTRLAB implements Plugin.PluginBase { } for (const src of URLs) { - const script = await fetchApi(`${this.site}${src}`); + const script = await this.fetchSite(`${this.site}${src}`); const raw = await script.text(); index = raw.indexOf(searchKey); if (index >= 0) { @@ -820,6 +820,12 @@ class WTRLAB implements Plugin.PluginBase { }), }); + if (apiResponse.headers.get('cf-mitigated') === 'challenge') { + throw new Error( + `Cloudflare protection detected (HTTP ${apiResponse.status}). Please open the plugin in WebView to solve the challenge, then try again.`, + ); + } + // Read as text first: an auth redirect or a Cloudflare challenge returns // HTML, and .json() would throw before we could report what came back. const rawBody = await apiResponse.text(); @@ -1035,7 +1041,7 @@ class WTRLAB implements Plugin.PluginBase { const end = start + batchSize - 1; try { - const response = await fetchApi( + const response = await this.fetchSite( `${this.site}api/chapters/${rawId}?start=${start}&end=${end}`, { headers: { From 1ab2f1f9fe840c88acf60ea5d1bbc5f25f81c75d Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 8 Oct 2026 09:35:18 +0800 Subject: [PATCH 3/5] no-mistakes(review): propagate wtrlab Cloudflare error from chapter fetch --- plugins/english/wtrlab.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/english/wtrlab.ts b/plugins/english/wtrlab.ts index 4a7a0f36d..627bdb4f3 100644 --- a/plugins/english/wtrlab.ts +++ b/plugins/english/wtrlab.ts @@ -623,6 +623,7 @@ class WTRLAB implements Plugin.PluginBase { try { chapters = await this.fetchAllChapters(rawId, slug); } catch (error) { + if (String(error).includes('Cloudflare protection')) throw error; console.error('Failed to fetch chapters via API:', error); chapters = []; } @@ -658,7 +659,7 @@ class WTRLAB implements Plugin.PluginBase { combined = new Uint8Array(ciphertext.length + tag.length); // Make the ciphertext + tag format expected for decryption - (combined.set(ciphertext), combined.set(tag, ciphertext.length)); + combined.set(ciphertext), combined.set(tag, ciphertext.length); // Decrypt with encKey // Convert the key to bytes (first 32 characters of encKey) @@ -1080,6 +1081,7 @@ class WTRLAB implements Plugin.PluginBase { start += batchSize; } catch (error) { + if (String(error).includes('Cloudflare protection')) throw error; console.error(`Failed to fetch chapters ${start}-${end}:`, error); hasMore = false; break; From a0bcfa18aa728014449a03e9564bd6c9605d9ec7 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 8 Oct 2026 09:55:49 +0800 Subject: [PATCH 4/5] fix(english/wtrlab): show WebView hint only on a real Cloudflare challenge fetchSite labelled every 403/503 as a Cloudflare challenge, so a plain origin denial or outage told the user to solve a challenge in WebView. Detect the challenge with one rule, Cloudflare's own `cf-mitigated: challenge` marker, in a shared assertNotChallenged helper, and have fetchSite report any other failed response as a plain error naming the HTTP status and URL. Both errors carry the status. Every remaining fetch to wtr-lab.com now applies the same check: - fetchSite (page or data with nothing to parse on failure): finder, _next/data, Latest, novel page, chapter page fallbacks, chapter list. - challenge check only, keeping their existing failure handling: tokens page (non-fatal), key scripts (falls through to the next script), reader API (per-mode fallback), content_url payload (null fallback), sign-in redeem and session check (status notes). The Google translate call is not a wtr-lab.com request and is unchanged. The LNReader app's fetchApi returns React Native's fetch Response unchanged, so response headers such as cf-mitigated reach the plugin. --- plugins/english/wtrlab.ts | 58 +++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/plugins/english/wtrlab.ts b/plugins/english/wtrlab.ts index 627bdb4f3..36bd976db 100644 --- a/plugins/english/wtrlab.ts +++ b/plugins/english/wtrlab.ts @@ -107,18 +107,38 @@ class WTRLAB implements Plugin.PluginBase { } /** - * Fetch a wtr-lab page or JSON route, refusing a Cloudflare challenge. + * Throw a WebView hint when Cloudflare answered with its managed challenge. * - * The whole site sits behind a managed challenge that answers 403 with a - * "Just a moment..." page until it is solved in WebView. Parsing that page - * as wtr-lab markup used to surface as "Could not find __NEXT_DATA__" or a - * JSON syntax error, which hid the actual fix from the user. + * The whole site sits behind a challenge that answers with a + * "Just a moment..." page, marked `cf-mitigated: challenge`, until it is + * solved in WebView. Parsing that page as wtr-lab markup used to surface as + * "Could not find __NEXT_DATA__" or a JSON syntax error, which hid the + * actual fix from the user. The header is the only rule used, so a plain + * 403/503 from the origin is not mistaken for a challenge. + */ + assertNotChallenged(res: Response): void { + if (res.headers.get('cf-mitigated') === 'challenge') { + throw Object.assign( + new Error( + `Cloudflare protection detected (HTTP ${res.status}). Please open the plugin in WebView to solve the challenge, then try again.`, + ), + { status: res.status }, + ); + } + } + + /** + * Fetch a wtr-lab page or JSON route whose failure leaves nothing to parse: + * a challenge gets the WebView hint, any other failed response a plain + * error naming the status and URL. */ async fetchSite(url: string, init?: FetchInit): Promise { const res = await fetchApi(url, init); - if (res.status === 403 || res.status === 503) { - throw new Error( - `Cloudflare protection detected (HTTP ${res.status}). Please open the plugin in WebView to solve the challenge, then try again.`, + this.assertNotChallenged(res); + if (!res.ok) { + throw Object.assign( + new Error(`Request failed (HTTP ${res.status}): ${url}`), + { status: res.status }, ); } return res; @@ -162,6 +182,7 @@ class WTRLAB implements Plugin.PluginBase { ...(cookie && sameSite ? { Cookie: cookie } : {}), }, }); + this.assertNotChallenged(res); const text = await res.text(); try { return JSON.parse(text)?.data?.data ?? null; @@ -219,6 +240,7 @@ class WTRLAB implements Plugin.PluginBase { 'Referer': this.site, }, }); + this.assertNotChallenged(res); const landedOn = (res.url || '').replace(this.site, '/') || 'unknown'; return `Sign-in: redeem token HTTP ${res.status}, ended at ${landedOn}`; } catch (e) { @@ -239,6 +261,7 @@ class WTRLAB implements Plugin.PluginBase { ...(cookie ? { Cookie: cookie } : {}), }, }); + this.assertNotChallenged(res); const text = await res.text(); let body = null; try { @@ -427,9 +450,10 @@ class WTRLAB implements Plugin.PluginBase { } async fetchTokens() { - const body = await this.fetchSite(this.site + this.sourceLang).then(res => - res.text(), - ); + // Missing tokens are not fatal, so only a challenge stops here. + const res = await fetchApi(this.site + this.sourceLang); + this.assertNotChallenged(res); + const body = await res.text(); const $ = parseHTML(body); this.baggage = $('meta[name="baggage"]').attr('content') ?? ''; @@ -718,7 +742,9 @@ class WTRLAB implements Plugin.PluginBase { } for (const src of URLs) { - const script = await this.fetchSite(`${this.site}${src}`); + // A failed script just moves on to the next one; a challenge won't pass. + const script = await fetchApi(`${this.site}${src}`); + this.assertNotChallenged(script); const raw = await script.text(); index = raw.indexOf(searchKey); if (index >= 0) { @@ -821,11 +847,7 @@ class WTRLAB implements Plugin.PluginBase { }), }); - if (apiResponse.headers.get('cf-mitigated') === 'challenge') { - throw new Error( - `Cloudflare protection detected (HTTP ${apiResponse.status}). Please open the plugin in WebView to solve the challenge, then try again.`, - ); - } + this.assertNotChallenged(apiResponse); // Read as text first: an auth redirect or a Cloudflare challenge returns // HTML, and .json() would throw before we could report what came back. @@ -930,7 +952,7 @@ class WTRLAB implements Plugin.PluginBase { chapterContent.toString().startsWith('str:') ) { if (!loadedCheerio) { - const body = await fetchApi(url).then(res => res.text()); + const body = await this.fetchSite(url).then(res => res.text()); loadedCheerio = parseHTML(body); } From 3f70416aacf98971c6b60ff1580ea276ddc487f6 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 8 Oct 2026 09:59:27 +0800 Subject: [PATCH 5/5] no-mistakes(lint): suppress no-control-regex in wtrlab token regex --- plugins/english/wtrlab.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/english/wtrlab.ts b/plugins/english/wtrlab.ts index 36bd976db..2c7e94c64 100644 --- a/plugins/english/wtrlab.ts +++ b/plugins/english/wtrlab.ts @@ -35,6 +35,7 @@ function resolveTokens( ): string { if (!str) return ''; return str.replace( + // eslint-disable-next-line no-control-regex /%\{(?:"([^"]*)":\s*"?([A-Za-z0-9+/=_-]+)"?|([^|{}%\u0000-\u001F\u007F\u2028\u2029]+?)\s*\|\s*([A-Za-z0-9+/=_-]+))\}/g, (match, safe1, b64_1, safe2, b64_2) => { const safe = (safe1 ?? safe2 ?? '').trim();