From 3d7b6b42037e66eb283770533364c1ba0ac130c4 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Tue, 22 Sep 2026 18:02:00 +0800 Subject: [PATCH 1/2] fix(english/fucknovelpia): throw on rate-limited pages instead of empty chapters fucknovelpia intermittently serves anti-scrape pages (nginx 403, a custom "Blocked" 429 page, or a Cloudflare rate-limit page) whose bodies contain no .reader markup. parseChapter ignored the HTTP status and returned '' whenever .reader was missing, so a single throttled request rendered as an empty chapter in LNReader (reproduced: HTTP 429 body has no reader markup, parseChapter returned 0 chars). Route all plugin fetches through a shared helper that sends browser-like headers, waits out a 429 once (honoring Retry-After, capped at 10s), and throws with the HTTP status on a refused response. parseChapter now also throws when a 200 page has no reader markup instead of reporting a false empty chapter. Closes #2540 AI-authored (pi agent), human-reviewed before merge. Co-Authored-By: firstmate-crewmate --- plugins/english/fucknovelpia.ts | 50 +++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/plugins/english/fucknovelpia.ts b/plugins/english/fucknovelpia.ts index bd51b689a..037eb13d4 100644 --- a/plugins/english/fucknovelpia.ts +++ b/plugins/english/fucknovelpia.ts @@ -11,7 +11,36 @@ class FuckNovelpia implements Plugin.PluginBase { name = 'FuckNovelpia'; icon = 'src/en/fucknovelpia/icon.png'; site = 'https://fucknovelpia.com/'; - version = '1.1.1'; + version = '1.1.2'; + + browserHeaders = { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept-Language': 'en-US,en;q=0.9', + }; + + sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + // The site intermittently swaps in anti-scrape pages (nginx 403, a custom + // "Blocked" 429 page, or a Cloudflare rate-limit page) whose bodies contain + // no reader markup. Parsing those silently produced empty chapters/novel + // lists (issue #2540), so surface them as errors instead, and wait out the + // rate limiter once before giving up. + async fetchHtml(url: string): Promise { + let response = await fetchApi(url, { headers: this.browserHeaders }); + if (response.status === 429) { + const retryAfter = Number(response.headers.get('Retry-After')); + const waitMs = (isNaN(retryAfter) ? 2 : Math.min(retryAfter, 10)) * 1000; + await this.sleep(waitMs); + response = await fetchApi(url, { headers: this.browserHeaders }); + } + if (!response.ok) { + throw new Error(`FuckNovelpia returned HTTP ${response.status}`); + } + return await response.text(); + } // Returns false once the site has silently clamped us past the real last page. hasRequestedPage(cheerio: CheerioAPI, requestedPage: number): boolean { @@ -52,8 +81,7 @@ class FuckNovelpia implements Plugin.PluginBase { async parseLatestUpdates() { const link = this.site + 'updates.php'; - const response = await fetchApi(link); - const html = await response.text(); + const html = await this.fetchHtml(link); const novels: Plugin.NovelItem[] = []; let tempNovel: Partial = {}; const seen = new Set(); @@ -168,8 +196,7 @@ class FuckNovelpia implements Plugin.PluginBase { const link = this.site + 'search.php?' + params.toString(); - const result = await fetchApi(link); - const body = await result.text(); + const body = await this.fetchHtml(link); const loadedCheerio = parseHTML(body); @@ -178,8 +205,7 @@ class FuckNovelpia implements Plugin.PluginBase { } async parseNovel(novelPath: string): Promise { - const result = await fetchApi(this.site + novelPath); - const body = await result.text(); + const body = await this.fetchHtml(this.site + novelPath); const loadedCheerio = parseHTML(body); @@ -267,14 +293,15 @@ class FuckNovelpia implements Plugin.PluginBase { } async parseChapter(chapterPath: string): Promise { - const result = await fetchApi(this.site + chapterPath); - const body = await result.text(); + const body = await this.fetchHtml(this.site + chapterPath); const $ = parseHTML(body); const chapter = $('.reader').first(); if (!chapter.length) { - return ''; + // A 200 without reader markup is a changed/blocked page, not an empty + // chapter — never report a false empty-success. + throw new Error('Chapter content not found in page'); } // Remove things that aren't part of the chapter @@ -311,8 +338,7 @@ class FuckNovelpia implements Plugin.PluginBase { const link = this.site + 'search.php?' + params.toString(); - const result = await fetchApi(link); - const body = await result.text(); + const body = await this.fetchHtml(link); const loadedCheerio = parseHTML(body); if (!this.hasRequestedPage(loadedCheerio, page)) return []; From 3f98fb928dd1ba459d876328774755ccc82cd022 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Tue, 29 Sep 2026 10:17:53 +0800 Subject: [PATCH 2/2] fix(english/fucknovelpia): wait before retrying 429s without Retry-After The site's 429 responses omit Retry-After; Number(null) is 0, so the single retry fired immediately and hit the limiter again. Parse the header explicitly so the 2s fallback applies. --- plugins/english/fucknovelpia.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugins/english/fucknovelpia.ts b/plugins/english/fucknovelpia.ts index 037eb13d4..e007d181e 100644 --- a/plugins/english/fucknovelpia.ts +++ b/plugins/english/fucknovelpia.ts @@ -31,7 +31,12 @@ class FuckNovelpia implements Plugin.PluginBase { async fetchHtml(url: string): Promise { let response = await fetchApi(url, { headers: this.browserHeaders }); if (response.status === 429) { - const retryAfter = Number(response.headers.get('Retry-After')); + // Its 429s usually omit Retry-After, and Number(null) is 0, so parse it + // explicitly to keep the fallback wait instead of retrying instantly. + const retryAfter = parseInt( + response.headers.get('Retry-After') || '', + 10, + ); const waitMs = (isNaN(retryAfter) ? 2 : Math.min(retryAfter, 10)) * 1000; await this.sleep(waitMs); response = await fetchApi(url, { headers: this.browserHeaders });