From 1bd352830c9b8d39bde28ae74dbbe4307f4e7f47 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Sat, 17 Oct 2026 21:25:14 +0800 Subject: [PATCH 1/3] fix(english/relibrary): keep chapter text wrapped after the top navigation parseChapter removed whatever element followed the top navigation
, assuming it was the author/translator credits table. On Worldgate Online chapter 1 onwards that element is div.ppw-restricted-content, which holds the whole chapter, so those chapters came back empty; on chapters with no credits table (e.g. Arifureta) the first paragraph was dropped. Only remove the credits table itself. Also report early access (password protected) chapters and refused responses with a clear error instead of returning the password form or an empty chapter, and strip event handlers and script URLs from the returned HTML. Closes #2688 --- plugins/english/relibrary.ts | 55 ++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/plugins/english/relibrary.ts b/plugins/english/relibrary.ts index 8cbd1844b..f9384403d 100644 --- a/plugins/english/relibrary.ts +++ b/plugins/english/relibrary.ts @@ -163,7 +163,7 @@ class ReLibraryPlugin implements Plugin.PluginBase { name = 'Re:Library'; icon = 'src/en/relibrary/icon.png'; site = 'https://re-library.com'; - version = '1.0.3'; + version = '1.0.4'; imageRequestInit: Plugin.ImageRequestInit = { headers: { Referer: 'https://re-library.com/', @@ -344,11 +344,29 @@ class ReLibraryPlugin implements Plugin.PluginBase { async parseChapter(chapterPath: string): Promise { // parse chapter text here const result = await fetchApi(`${this.site}${chapterPath}`); + if (!result.ok) { + throw new Error( + `Could not load chapter ${chapterPath} (HTTP ${result.status})`, + ); + } const body = await result.text(); const loadedCheerio = loadCheerio(body); const entryContent = loadedCheerio('.entry-content'); + + // Early access chapters only show a password form until they unlock. + const passwordForm = entryContent.find('form.post-password-form'); + if (passwordForm.length) { + const unlock = passwordForm + .find('.ppw-pcp-pf-desc-below-form') + .text() + .trim(); + throw new Error( + `This chapter is password protected (early access) on Re:Library${unlock ? ` ${unlock}` : ''}`, + ); + } + const pageLinkHr = entryContent.find('.PageLink + hr').first(); if (pageLinkHr.length) { // Remove all previous siblings before the .PageLink + hr @@ -361,7 +379,12 @@ class ReLibraryPlugin implements Plugin.PluginBase { if (pageLink.length) { pageLink.remove(); } - pageLinkHr.next().remove(); + // The credits table follows the top navigation, either on its own or + // as the first child of a wrapper (div.ppw-restricted-content) that + // holds the whole chapter, so only remove the table itself. + const afterNav = pageLinkHr.next(); + if (afterNav.is('table')) afterNav.remove(); + else afterNav.children().first().filter('table').remove(); pageLinkHr.remove(); } @@ -378,7 +401,33 @@ class ReLibraryPlugin implements Plugin.PluginBase { hrAfterPageLink.remove(); } - return entryContent.html() || ''; + // The app renders this HTML as is, so drop event handlers and URLs that + // would run script. + entryContent.find('script, style, noscript').remove(); + entryContent.find('*').each((_i, el) => { + const node = loadedCheerio(el); + for (const name of Object.keys(el.attribs)) { + if (/^on/i.test(name)) { + node.removeAttr(name); + } else if (name === 'href' || name === 'src') { + const value = el.attribs[name] + .split('') + .filter(c => c.charCodeAt(0) > 32) + .join('') + .toLowerCase(); + if ( + /^(javascript|vbscript):/.test(value) || + (value.startsWith('data:') && !value.startsWith('data:image/')) + ) { + node.removeAttr(name); + } + } + } + }); + + const html = entryContent.html()?.trim(); + if (!html) throw new Error(`No chapter text found at ${chapterPath}`); + return html; } async searchNovels( From 9a3c3f07949fcd7f77ac75a652429b6896392802 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Sat, 17 Oct 2026 21:27:39 +0800 Subject: [PATCH 2/3] fix(english/relibrary): parse the redesigned popular and latest listings Re:Library replaced the most-popular list with div.relibrary-novel-card cards, so popularNovels returned no novels, and the latest listing's title heading now wraps a whole novel card, so names came back with the card's text. Read the card title, link, and cover instead, and throw with the HTTP status when the listing request is refused. --- plugins/english/relibrary.ts | 55 ++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/plugins/english/relibrary.ts b/plugins/english/relibrary.ts index f9384403d..3f5bc4cc1 100644 --- a/plugins/english/relibrary.ts +++ b/plugins/english/relibrary.ts @@ -178,49 +178,48 @@ class ReLibraryPlugin implements Plugin.PluginBase { private async popularNovelsInner(url: string): Promise { const novels: Plugin.NovelItem[] = []; const result = await fetchApi(url); + if (!result.ok) { + throw new Error(`Could not load ${url} (HTTP ${result.status})`); + } const body = await result.text(); const loadedCheerio = loadCheerio(body); - loadedCheerio('.entry-content > ol > li').each((_i, el) => { - const novel: Partial = {}; - novel.name = loadedCheerio(el).find('h3 > a').text(); - novel.path = loadedCheerio(el) - .find('table > tbody > tr > td > a') - .attr('href'); - if (novel.name === undefined || novel.path === undefined) return; - novel.cover = - loadedCheerio(el) - .find('table > tbody > tr > td > a > img') - .attr('data-cfsrc') || - loadedCheerio(el) - .find('table > tbody > tr > td > a > img') - .attr('src') || - defaultCover; - novel.path = new URL(novel.path, this.site).pathname; - novels.push(novel as Plugin.NovelItem); - }); + loadedCheerio('.relibrary-top-novels .relibrary-novel-card').each( + (_i, el) => { + const novel: Partial = {}; + const title = loadedCheerio(el).find('.relibrary-title a'); + novel.name = title.text().trim(); + novel.path = title.attr('href'); + if (!novel.name || novel.path === undefined) return; + const cover = loadedCheerio(el).find('img.relibrary-cover'); + novel.cover = + cover.attr('data-cfsrc') || cover.attr('src') || defaultCover; + novel.path = new URL(novel.path, this.site).pathname; + novels.push(novel as Plugin.NovelItem); + }, + ); return novels; } private async lastestNovelsInner(url: string): Promise { const novels: Plugin.NovelItem[] = []; const result = await fetchApi(url); + if (!result.ok) { + throw new Error(`Could not load ${url} (HTTP ${result.status})`); + } const body = await result.text(); const loadedCheerio = loadCheerio(body); loadedCheerio('article.type-page.page').each((_i, el) => { const novel: Partial = {}; - novel.name = loadedCheerio(el).find('.entry-title').text(); - novel.path = loadedCheerio(el).find('.entry-title a').attr('href'); - if (novel.path === undefined || novel.name === undefined) return; + // The title heading also wraps the novel card, so only read its link. + const title = loadedCheerio(el).find('.entry-title > a').first(); + novel.name = title.text().trim(); + novel.path = title.attr('href'); + if (!novel.name || novel.path === undefined) return; + const cover = loadedCheerio(el).find('img.relibrary-cover'); novel.cover = - loadedCheerio(el) - .find('.entry-content > table > tbody > tr > td > a >img') - .attr('data-cfsrc') || - loadedCheerio(el) - .find('.entry-content > table > tbody > tr > td > a >img') - .attr('src') || - defaultCover; + cover.attr('data-cfsrc') || cover.attr('src') || defaultCover; novel.path = new URL(novel.path, this.site).pathname; novels.push(novel as Plugin.NovelItem); }); From b4a8ee3bc02e4958bc389907370caddd18a9835a Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Sun, 18 Oct 2026 08:24:31 +0800 Subject: [PATCH 3/3] no-mistakes(ci): The Plugin Live Check failure was re-library.com returning HTTP 403 to the CI runner. I changed the three `!result.ok` throws in `plugins/english/relibrary.ts` (`popularNovelsInner`, `lastestNovelsInner`, `parseChapter`) to throw `Object.assign(new Error(...), { status: result.status, response: result })`, as `ao3.ts` does. The error messages are unchanged. I haven't confirmed that `isNetworkOrBlockError()` in `scripts/live-check-plugin.js` now reports a 403/503 as INCONCLUSIVE, because I didn't reproduce a block locally. Prettier and eslint are clean. `npm run check:plugin` passes all four checks locally: 30 popular novels, 3 search results, 413 chapters, 17265 chars of chapter text. The CI job itself hasn't been re-run --- plugins/english/relibrary.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/plugins/english/relibrary.ts b/plugins/english/relibrary.ts index 3f5bc4cc1..96b6394b4 100644 --- a/plugins/english/relibrary.ts +++ b/plugins/english/relibrary.ts @@ -179,7 +179,10 @@ class ReLibraryPlugin implements Plugin.PluginBase { const novels: Plugin.NovelItem[] = []; const result = await fetchApi(url); if (!result.ok) { - throw new Error(`Could not load ${url} (HTTP ${result.status})`); + throw Object.assign( + new Error(`Could not load ${url} (HTTP ${result.status})`), + { status: result.status, response: result }, + ); } const body = await result.text(); @@ -205,7 +208,10 @@ class ReLibraryPlugin implements Plugin.PluginBase { const novels: Plugin.NovelItem[] = []; const result = await fetchApi(url); if (!result.ok) { - throw new Error(`Could not load ${url} (HTTP ${result.status})`); + throw Object.assign( + new Error(`Could not load ${url} (HTTP ${result.status})`), + { status: result.status, response: result }, + ); } const body = await result.text(); @@ -344,8 +350,11 @@ class ReLibraryPlugin implements Plugin.PluginBase { // parse chapter text here const result = await fetchApi(`${this.site}${chapterPath}`); if (!result.ok) { - throw new Error( - `Could not load chapter ${chapterPath} (HTTP ${result.status})`, + throw Object.assign( + new Error( + `Could not load chapter ${chapterPath} (HTTP ${result.status})`, + ), + { status: result.status, response: result }, ); } const body = await result.text();