diff --git a/plugins/english/fucknovelpia.ts b/plugins/english/fucknovelpia.ts index bd51b689a..e007d181e 100644 --- a/plugins/english/fucknovelpia.ts +++ b/plugins/english/fucknovelpia.ts @@ -11,7 +11,41 @@ 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) { + // 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 }); + } + 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 +86,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 +201,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 +210,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 +298,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 +343,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 [];