From c7ff8bb52578bfd026a2718561fdb9a55c273ec9 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 15 Oct 2026 19:46:00 +0800 Subject: [PATCH 1/8] feat(english/onlinereadfreenovel): add Online Read Free Novel source Standalone English source for onlinereadfreenovel.com (issue #149). Browse popular and genre listings, search, read novel details, and read text. The site splits each book across numbered pages, so each page is listed as a chapter; empty pages are skipped. --- plugins/english/onlinereadfreenovel.ts | 197 ++++++++++++++++++ .../src/en/onlinereadfreenovel/icon.png | Bin 0 -> 15352 bytes 2 files changed, 197 insertions(+) create mode 100644 plugins/english/onlinereadfreenovel.ts create mode 100644 public/static/src/en/onlinereadfreenovel/icon.png diff --git a/plugins/english/onlinereadfreenovel.ts b/plugins/english/onlinereadfreenovel.ts new file mode 100644 index 000000000..d9a68e30a --- /dev/null +++ b/plugins/english/onlinereadfreenovel.ts @@ -0,0 +1,197 @@ +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { FilterTypes } from '@libs/filterInputs'; +import { CheerioAPI, load as loadCheerio } from 'cheerio'; +import { defaultCover } from '@libs/defaultCover'; + +/** + * Notes on the site (onlinereadfreenovel.com): + * - Listing: `/allbooks/` and `/gr//` list books as `article.box` + * items (`h2.title a` + preview image); further pages are `/p//`. + * - Search: `/search_by_author_title_series/q/` lists `ul.books li a`. + * - Book: `//-_read.html` is page 1 of the text. The text + * is split across numbered pages, `//p,,-_read.html`. + * Each page is a plugin "chapter". The site has no chapter list, so the + * pages are walked until one has no next link. + * - Some pages are served empty (e.g. page 2 of some books); those are skipped + * rather than listed as empty chapters. + */ + +const MAX_PAGES = 300; + +class OnlineReadFreeNovelPlugin implements Plugin.PluginBase { + id = 'onlinereadfreenovel'; + name = 'Online Read Free Novel'; + icon = 'src/en/onlinereadfreenovel/icon.png'; + site = 'https://onlinereadfreenovel.com'; + version = '1.0.0'; + + filters = { + genre: { + label: 'Genre', + value: 'all', + options: [ + { label: 'All', value: 'all' }, + { label: 'Action & Adventure', value: 'actions_adventure' }, + { label: 'Fantasy', value: 'fantasy' }, + { label: 'History & Fiction', value: 'historical_fiction' }, + { label: 'Horror', value: 'horror' }, + { label: 'Humor', value: 'humor' }, + { label: 'Mystery & Detective', value: 'mystery_detective' }, + { label: 'Romance & Love', value: 'romance_love' }, + { label: 'Science Fiction', value: 'science_fiction' }, + { label: 'Thrillers & Crime', value: 'thrillers_crime' }, + { label: 'Western', value: 'western' }, + { label: 'Young Adult', value: 'young_adult' }, + ], + type: FilterTypes.Picker, + }, + }; + + async popularNovels( + pageNo: number, + { filters }: Plugin.PopularNovelsOptions, + ): Promise { + const genre = filters?.genre?.value || 'all'; + const base = genre === 'all' ? '/allbooks/' : '/gr/' + genre + '/'; + const path = pageNo > 1 ? base + 'p/' + pageNo + '/' : base; + const $ = await this.fetchDom(path); + return this.parseBookList($); + } + + async parseNovel(novelPath: string): Promise { + const $ = await this.fetchDom(novelPath); + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: $('h1').first().text().trim() || 'Untitled', + author: $('.box_in > b > a').first().text().trim(), + }; + + const cover = $('#textToRead img').first().attr('src'); + novel.cover = cover ? this.toAbsolute(cover) : defaultCover; + + novel.chapters = await this.parsePages(novelPath, $); + return novel; + } + + async parseChapter(chapterPath: string): Promise { + const $ = await this.fetchDom(chapterPath); + $('#textToRead').find('img, a.highslide, script, style').remove(); + + // The text is one block of `
`-separated lines; a run of two or more + // line breaks marks a paragraph boundary. + const html = ($('#textToRead').html() || '').replace(/<\/?p[^>]*>/gi, ''); + return html + .split(/(?:\s*){2,}/i) + .map(part => + part.replace( + /^(?:| |\u00a0|\s)+|(?: |\u00a0|\s)+$/gi, + '', + ), + ) + .filter(part => part.length > 0) + .map(part => '

' + part + '

') + .join(''); + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + const base = + '/search_by_author_title_series/q/' + encodeURIComponent(searchTerm); + const path = pageNo > 1 ? base + '/p/' + pageNo : base; + const $ = await this.fetchDom(path); + + return $('ul.books li a') + .toArray() + .flatMap(el => { + const item = $(el); + const href = item.attr('href'); + if (!href) return []; + const style = item.find('.i1').attr('style') || ''; + const cover = style.match(/url\(["']?([^"')]+)["']?\)/); + return [ + { + name: item.find('.h2').attr('title') || item.text().trim(), + path: this.toPath(href), + cover: cover ? this.toAbsolute(cover[1]) : defaultCover, + }, + ]; + }); + } + + resolveUrl = (path: string) => this.site + path; + + parseBookList($: CheerioAPI): Plugin.NovelItem[] { + return $('article.box') + .toArray() + .flatMap(el => { + const link = $(el).find('h2.title a').first(); + const href = link.attr('href'); + if (!href) return []; + const src = $(el).find('div.text img').first().attr('src'); + return [ + { + name: link.text().trim(), + path: this.toPath(href), + cover: src ? this.toAbsolute(src) : defaultCover, + }, + ]; + }); + } + + /** + * Walks the text pages of a book. Page 1 is the novel path itself; page `n` + * is `p,,` inserted before the id. Stops at the last page (no next + * link), or after two empty pages in a row. + */ + async parsePages( + novelPath: string, + firstPage: CheerioAPI, + ): Promise { + const chapters: Plugin.ChapterItem[] = []; + let emptyRun = 0; + + for (let page = 1; page <= MAX_PAGES; page++) { + const pagePath = this.pagePath(novelPath, page); + const $ = page === 1 ? firstPage : await this.fetchDom(pagePath); + const hasText = $('#textToRead').text().trim().length > 0; + + if (hasText) { + emptyRun = 0; + chapters.push({ + name: 'Page ' + page, + path: pagePath, + chapterNumber: chapters.length + 1, + }); + if ($('.page_next a').length === 0) break; + } else if (++emptyRun >= 2) { + break; + } + } + + return chapters; + } + + pagePath(novelPath: string, page: number): string { + if (page <= 1) return novelPath; + return novelPath.replace(/\/(\d+-[^/]+_read\.html)$/, '/p,' + page + ',$1'); + } + + async fetchDom(path: string): Promise { + const res = await fetchApi(this.site + path); + return loadCheerio(await res.text()); + } + + toPath(href: string): string { + return new URL(href, this.site).pathname; + } + + toAbsolute(src: string): string { + return new URL(src, this.site).href; + } +} + +export default new OnlineReadFreeNovelPlugin(); diff --git a/public/static/src/en/onlinereadfreenovel/icon.png b/public/static/src/en/onlinereadfreenovel/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..430e4725b7387a1c56e6505116e563f4eed8d290 GIT binary patch literal 15352 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 Date: Thu, 15 Oct 2026 19:52:28 +0800 Subject: [PATCH 2/8] no-mistakes(review): warn when onlinereadfreenovel page walk hits cap --- plugins/english/onlinereadfreenovel.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/plugins/english/onlinereadfreenovel.ts b/plugins/english/onlinereadfreenovel.ts index d9a68e30a..bb4482413 100644 --- a/plugins/english/onlinereadfreenovel.ts +++ b/plugins/english/onlinereadfreenovel.ts @@ -17,6 +17,10 @@ import { defaultCover } from '@libs/defaultCover'; * rather than listed as empty chapters. */ +// Upper bound on text pages walked per book. Every page is one sequential +// request made before the novel detail returns, so the cap keeps a single +// book from issuing an unbounded number of requests. Books longer than this +// are truncated, and parsePages logs a warning when it hits the cap. const MAX_PAGES = 300; class OnlineReadFreeNovelPlugin implements Plugin.PluginBase { @@ -166,12 +170,15 @@ class OnlineReadFreeNovelPlugin implements Plugin.PluginBase { path: pagePath, chapterNumber: chapters.length + 1, }); - if ($('.page_next a').length === 0) break; + if ($('.page_next a').length === 0) return chapters; } else if (++emptyRun >= 2) { - break; + return chapters; } } + console.warn( + `onlinereadfreenovel: stopped at MAX_PAGES (${MAX_PAGES}) for ${novelPath}; remaining pages were not listed`, + ); return chapters; } From 2b1f7709ee1323593ad20db3f48413237203409b Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 15 Oct 2026 19:53:57 +0800 Subject: [PATCH 3/8] no-mistakes(review): fail on HTTP errors, stop at non-URL pages, warn precisely --- plugins/english/onlinereadfreenovel.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/plugins/english/onlinereadfreenovel.ts b/plugins/english/onlinereadfreenovel.ts index bb4482413..ad4385e5b 100644 --- a/plugins/english/onlinereadfreenovel.ts +++ b/plugins/english/onlinereadfreenovel.ts @@ -160,6 +160,7 @@ class OnlineReadFreeNovelPlugin implements Plugin.PluginBase { for (let page = 1; page <= MAX_PAGES; page++) { const pagePath = this.pagePath(novelPath, page); + if (page > 1 && pagePath === novelPath) return chapters; const $ = page === 1 ? firstPage : await this.fetchDom(pagePath); const hasText = $('#textToRead').text().trim().length > 0; @@ -171,14 +172,16 @@ class OnlineReadFreeNovelPlugin implements Plugin.PluginBase { chapterNumber: chapters.length + 1, }); if ($('.page_next a').length === 0) return chapters; + if (page === MAX_PAGES) { + console.warn( + `onlinereadfreenovel: stopped at MAX_PAGES (${MAX_PAGES}) for ${novelPath}; remaining pages were not listed`, + ); + } } else if (++emptyRun >= 2) { return chapters; } } - console.warn( - `onlinereadfreenovel: stopped at MAX_PAGES (${MAX_PAGES}) for ${novelPath}; remaining pages were not listed`, - ); return chapters; } @@ -189,6 +192,7 @@ class OnlineReadFreeNovelPlugin implements Plugin.PluginBase { async fetchDom(path: string): Promise { const res = await fetchApi(this.site + path); + if (!res.ok) throw new Error(`HTTP ${res.status} for ${path}`); return loadCheerio(await res.text()); } From a237e1463db4f721d71f9371d1924f3800e7aa57 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 15 Oct 2026 19:57:50 +0800 Subject: [PATCH 4/8] no-mistakes(review): treat non-OK walk pages as empty pages --- plugins/english/onlinereadfreenovel.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/plugins/english/onlinereadfreenovel.ts b/plugins/english/onlinereadfreenovel.ts index ad4385e5b..d0c0dcb7d 100644 --- a/plugins/english/onlinereadfreenovel.ts +++ b/plugins/english/onlinereadfreenovel.ts @@ -161,8 +161,8 @@ class OnlineReadFreeNovelPlugin implements Plugin.PluginBase { for (let page = 1; page <= MAX_PAGES; page++) { const pagePath = this.pagePath(novelPath, page); if (page > 1 && pagePath === novelPath) return chapters; - const $ = page === 1 ? firstPage : await this.fetchDom(pagePath); - const hasText = $('#textToRead').text().trim().length > 0; + const $ = page === 1 ? firstPage : await this.fetchPageDom(pagePath); + const hasText = !!$ && $('#textToRead').text().trim().length > 0; if (hasText) { emptyRun = 0; @@ -191,9 +191,14 @@ class OnlineReadFreeNovelPlugin implements Plugin.PluginBase { } async fetchDom(path: string): Promise { + const $ = await this.fetchPageDom(path); + if (!$) throw new Error(`HTTP error for ${path}`); + return $; + } + + async fetchPageDom(path: string): Promise { const res = await fetchApi(this.site + path); - if (!res.ok) throw new Error(`HTTP ${res.status} for ${path}`); - return loadCheerio(await res.text()); + return res.ok ? loadCheerio(await res.text()) : null; } toPath(href: string): string { From b40e766badc01b2274ca54339e4b78b7fa73a414 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 15 Oct 2026 21:47:15 +0800 Subject: [PATCH 5/8] no-mistakes(document): document non-OK walk pages as empty in parsePages --- plugins/english/onlinereadfreenovel.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/english/onlinereadfreenovel.ts b/plugins/english/onlinereadfreenovel.ts index d0c0dcb7d..20f7538cd 100644 --- a/plugins/english/onlinereadfreenovel.ts +++ b/plugins/english/onlinereadfreenovel.ts @@ -149,7 +149,8 @@ class OnlineReadFreeNovelPlugin implements Plugin.PluginBase { /** * Walks the text pages of a book. Page 1 is the novel path itself; page `n` * is `p,,` inserted before the id. Stops at the last page (no next - * link), or after two empty pages in a row. + * link), or after two empty pages in a row. A page answered with a non-OK + * status counts as empty, so it is skipped rather than failing the book. */ async parsePages( novelPath: string, From 6e93de51a78dd120a62790108d8fa90c9a49de83 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 15 Oct 2026 21:53:00 +0800 Subject: [PATCH 6/8] no-mistakes(ci): Plugin Live Check failure for plugins/english/onlinereadfreenovel.ts fixed. Cause: fetchDom threw a plain Error with no HTTP status on a non-OK response, so the live check could not tell a site block (403/503, INCONCLUSIVE by design) from a plugin bug and reported FAIL for popularNovels /allbooks/. Fix: fetchDom now throws with status: res.status, the same convention other plugins use. Verified: a mocked 403 or 503 on /allbooks/ now reports INCONCLUSIVE (was FAIL); a mocked 500 still reports FAIL; the unmocked live check passes all four steps; eslint and prettier pass on the file. Remaining risk: the CI log does not show the runner's actual status, so if CI got a 500 or 429 the check will still FAIL. Two tsc errors on the filters declaration predate this change. Change is uncommitted in the worktree --- plugins/english/onlinereadfreenovel.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/plugins/english/onlinereadfreenovel.ts b/plugins/english/onlinereadfreenovel.ts index 20f7538cd..59fc45423 100644 --- a/plugins/english/onlinereadfreenovel.ts +++ b/plugins/english/onlinereadfreenovel.ts @@ -192,9 +192,13 @@ class OnlineReadFreeNovelPlugin implements Plugin.PluginBase { } async fetchDom(path: string): Promise { - const $ = await this.fetchPageDom(path); - if (!$) throw new Error(`HTTP error for ${path}`); - return $; + const res = await fetchApi(this.site + path); + if (!res.ok) { + throw Object.assign(new Error(`HTTP error for ${path}`), { + status: res.status, + }); + } + return loadCheerio(await res.text()); } async fetchPageDom(path: string): Promise { From 45a1a628a3bde5a638c41a5bf03bfac760431921 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Fri, 16 Oct 2026 06:25:39 +0800 Subject: [PATCH 7/8] fix(english/onlinereadfreenovel): pass Cloudflare from CI, list every page, recover search links - Send a client-identifying User-Agent: Cloudflare challenges browser and generic tool UAs from datacenter networks (GitHub runners got 403 on every request shape tried), but lets a named client through. - Keep walking past empty pages after a page that links onward; the site serves runs of broken pages mid-book, and stopping at two lost most of long books (Anna Karenina: 122 of 219 pages). - Fetch pages six at a time (Anna Karenina: 88s to 18s). - Name pages after the chapter that opens on them, skipping contents pages. - Rebuild search links the site truncates (e.g. `..._stone.ht`) from the link or the cover file name, so those results open. - Throw on an empty book or page instead of returning a blank title or text. - Drop the stray `-->` paragraph; type filters with `satisfies Filters`. --- plugins/english/onlinereadfreenovel.ts | 190 ++++++++++++++++++------- 1 file changed, 139 insertions(+), 51 deletions(-) diff --git a/plugins/english/onlinereadfreenovel.ts b/plugins/english/onlinereadfreenovel.ts index 59fc45423..e2042c228 100644 --- a/plugins/english/onlinereadfreenovel.ts +++ b/plugins/english/onlinereadfreenovel.ts @@ -1,6 +1,6 @@ import { fetchApi } from '@libs/fetch'; import { Plugin } from '@/types/plugin'; -import { FilterTypes } from '@libs/filterInputs'; +import { Filters, FilterTypes } from '@libs/filterInputs'; import { CheerioAPI, load as loadCheerio } from 'cheerio'; import { defaultCover } from '@libs/defaultCover'; @@ -9,19 +9,43 @@ import { defaultCover } from '@libs/defaultCover'; * - Listing: `/allbooks/` and `/gr//` list books as `article.box` * items (`h2.title a` + preview image); further pages are `/p//`. * - Search: `/search_by_author_title_series/q/` lists `ul.books li a`. + * Its links are cut to a fixed length, sometimes inside the slug, so + * `bookPath` rebuilds them. * - Book: `//-_read.html` is page 1 of the text. The text * is split across numbered pages, `//p,,-_read.html`. * Each page is a plugin "chapter". The site has no chapter list, so the - * pages are walked until one has no next link. - * - Some pages are served empty (e.g. page 2 of some books); those are skipped - * rather than listed as empty chapters. + * pages are walked until the last one (a page with text but no next link). + * - Some pages are served as an empty 200 response, in runs of up to six seen + * so far, with text continuing after them. Those pages are skipped rather + * than listed as empty chapters, and do not end the walk. */ -// Upper bound on text pages walked per book. Every page is one sequential -// request made before the novel detail returns, so the cap keeps a single -// book from issuing an unbounded number of requests. Books longer than this -// are truncated, and parsePages logs a warning when it hits the cap. -const MAX_PAGES = 300; +// Cloudflare on this site challenges requests from datacenter networks (seen +// from GitHub's runners) that send a browser User-Agent or a generic one such +// as node or curl, but lets a client that names itself through. +const HEADERS = { 'User-Agent': 'Mozilla/5.0 (compatible; LNReader)' }; + +// Upper bound on text pages walked per book, so a single book cannot issue an +// unbounded number of requests. Books longer than this are truncated, and +// parsePages logs a warning when it hits the cap. +const MAX_PAGES = 400; + +// Pages fetched at once while walking a book. Fetching them one at a time +// took about 90 seconds for a 240-page book. +const PAGE_BATCH = 6; + +// Empty pages in a row after a page that links to a next one before the walk +// gives up. It only ends the walk when the book's real last page is empty. +const MAX_EMPTY_RUN = 12; + +// A paragraph that opens a chapter, e.g. "Chapter 1 The Boy Who Lived", +// "Chapter One", "Prologue". Sentences (with end punctuation) are excluded. +const HEADING = /^(?:chapter\s+[\w-]+|prologue|epilogue)\b[^.!?]{0,60}$/i; + +// Text pages hold at most a few chapter openings (two seen so far); a page +// with more is a table of contents (18 and 21 seen) and is not named after +// its first entry. +const MAX_HEADINGS = 4; class OnlineReadFreeNovelPlugin implements Plugin.PluginBase { id = 'onlinereadfreenovel'; @@ -50,7 +74,7 @@ class OnlineReadFreeNovelPlugin implements Plugin.PluginBase { ], type: FilterTypes.Picker, }, - }; + } satisfies Filters; async popularNovels( pageNo: number, @@ -66,9 +90,15 @@ class OnlineReadFreeNovelPlugin implements Plugin.PluginBase { async parseNovel(novelPath: string): Promise { const $ = await this.fetchDom(novelPath); + // A book page that failed on the site comes back as an empty 200 + // response; throw rather than overwrite the library entry with a blank + // title and no chapters. + const name = $('h1').first().text().trim(); + if (!name) throw new Error(`No book found at ${novelPath}`); + const novel: Plugin.SourceNovel = { path: novelPath, - name: $('h1').first().text().trim() || 'Untitled', + name, author: $('.box_in > b > a').first().text().trim(), }; @@ -80,23 +110,11 @@ class OnlineReadFreeNovelPlugin implements Plugin.PluginBase { } async parseChapter(chapterPath: string): Promise { - const $ = await this.fetchDom(chapterPath); - $('#textToRead').find('img, a.highslide, script, style').remove(); - - // The text is one block of `
`-separated lines; a run of two or more - // line breaks marks a paragraph boundary. - const html = ($('#textToRead').html() || '').replace(/<\/?p[^>]*>/gi, ''); - return html - .split(/(?:\s*){2,}/i) - .map(part => - part.replace( - /^(?:| |\u00a0|\s)+|(?: |\u00a0|\s)+$/gi, - '', - ), - ) - .filter(part => part.length > 0) - .map(part => '

' + part + '

') - .join(''); + const paragraphs = this.paragraphs(await this.fetchDom(chapterPath)); + if (paragraphs.length === 0) { + throw new Error(`The site returned no text for ${chapterPath}`); + } + return paragraphs.map(part => '

' + part + '

').join(''); } async searchNovels( @@ -115,12 +133,14 @@ class OnlineReadFreeNovelPlugin implements Plugin.PluginBase { const href = item.attr('href'); if (!href) return []; const style = item.find('.i1').attr('style') || ''; - const cover = style.match(/url\(["']?([^"')]+)["']?\)/); + const cover = style.match(/url\(["']?([^"')]+)["']?\)/)?.[1]; + const path = this.bookPath(href, cover); + if (!path) return []; return [ { name: item.find('.h2').attr('title') || item.text().trim(), - path: this.toPath(href), - cover: cover ? this.toAbsolute(cover[1]) : defaultCover, + path, + cover: cover ? this.toAbsolute(cover) : defaultCover, }, ]; }); @@ -146,53 +166,121 @@ class OnlineReadFreeNovelPlugin implements Plugin.PluginBase { }); } + /** + * Returns the book's `_read.html` path for a search link. Search links + * point at `//-.html` but are cut to a fixed length, e.g. + * `.../1301-harry_potter_and_the_philosophers_stone.ht` or + * `.../458266-the_sociology_of_harry_potter_22_enchanting_`. Book pages need + * the exact slug, so when the cut falls inside it, the slug is taken from + * the cover file name (`[_]_preview.jpg`). Returns undefined when + * the slug cannot be recovered. + */ + bookPath(href: string, cover?: string): string | undefined { + const path = this.toPath(href); + if (/\/\d+-[^/]+_read\.html$/.test(path)) return path; + const match = path.match(/^(\/[^/]+\/\d+-)([^/]*)$/); + if (!match) return undefined; + const [, prefix, tail] = match; + + const dot = tail.indexOf('.'); + if (dot > 0) return prefix + tail.slice(0, dot) + '_read.html'; + + const file = cover?.match(/\/([^/]+)_preview\.\w+$/)?.[1]; + if (!file) return undefined; + const slug = [file, file.replace(/^\d+_/, '')].find(name => + name.startsWith(tail), + ); + return slug ? prefix + slug + '_read.html' : undefined; + } + /** * Walks the text pages of a book. Page 1 is the novel path itself; page `n` - * is `p,,` inserted before the id. Stops at the last page (no next - * link), or after two empty pages in a row. A page answered with a non-OK - * status counts as empty, so it is skipped rather than failing the book. + * is `p,,` inserted before the id. Pages are fetched PAGE_BATCH at a time + * and handled in order. The walk ends at the first page with text and no + * next link, or after MAX_EMPTY_RUN empty pages in a row. A page answered + * with no text or a non-OK status counts as empty and is skipped. */ async parsePages( novelPath: string, firstPage: CheerioAPI, ): Promise { const chapters: Plugin.ChapterItem[] = []; + // A path not in the `_read.html` form cannot be paged; list page 1 only. + const lastPage = this.pagePath(novelPath, 2) === novelPath ? 1 : MAX_PAGES; let emptyRun = 0; - for (let page = 1; page <= MAX_PAGES; page++) { - const pagePath = this.pagePath(novelPath, page); - if (page > 1 && pagePath === novelPath) return chapters; - const $ = page === 1 ? firstPage : await this.fetchPageDom(pagePath); - const hasText = !!$ && $('#textToRead').text().trim().length > 0; + for (let start = 1; start <= lastPage; start += PAGE_BATCH) { + const pages: number[] = []; + for (let page = start; page < start + PAGE_BATCH; page++) { + if (page <= lastPage) pages.push(page); + } + const doms = await Promise.all( + pages.map(page => + page === 1 + ? firstPage + : this.fetchPageDom(this.pagePath(novelPath, page)), + ), + ); - if (hasText) { + for (let i = 0; i < pages.length; i++) { + const $ = doms[i]; + const paragraphs = $ ? this.paragraphs($) : []; + if (!$ || paragraphs.length === 0) { + if (++emptyRun >= MAX_EMPTY_RUN) return chapters; + continue; + } emptyRun = 0; + const headings = paragraphs + .map(part => part.replace(/<[^>]*>/g, '').trim()) + .filter(text => HEADING.test(text)); + // Many headings on one page is a table of contents, not chapters. + const heading = + headings.length <= MAX_HEADINGS ? headings[0] : undefined; chapters.push({ - name: 'Page ' + page, - path: pagePath, + name: 'Page ' + pages[i] + (heading ? ': ' + heading : ''), + path: this.pagePath(novelPath, pages[i]), chapterNumber: chapters.length + 1, }); if ($('.page_next a').length === 0) return chapters; - if (page === MAX_PAGES) { - console.warn( - `onlinereadfreenovel: stopped at MAX_PAGES (${MAX_PAGES}) for ${novelPath}; remaining pages were not listed`, - ); - } - } else if (++emptyRun >= 2) { - return chapters; } } + if (lastPage === MAX_PAGES) { + console.warn( + `onlinereadfreenovel: stopped at MAX_PAGES (${MAX_PAGES}) for ${novelPath}; remaining pages were not listed`, + ); + } return chapters; } + /** + * The text of a page as paragraph HTML. The text is one block of + * `
`-separated lines; a run of two or more line breaks marks a + * paragraph boundary. Images, scripts, comments, empty paragraphs and a + * stray `-->` paragraph left by the site's markup are dropped. + */ + paragraphs($: CheerioAPI): string[] { + const text = $('#textToRead'); + text.find('img, a.highslide, script, style').remove(); + const html = (text.html() || '').replace(/<\/?p[^>]*>/gi, ''); + return html + .split(/(?:\s*){2,}/i) + .map(part => + part + .replace(//g, '') + .replace(/^(?:| |\u00a0|\s)+/i, '') + .replace(/(?:| |\u00a0|\s)+$/i, ''), + ) + .filter(part => part.length > 0 && part !== '-->'); + } + pagePath(novelPath: string, page: number): string { if (page <= 1) return novelPath; return novelPath.replace(/\/(\d+-[^/]+_read\.html)$/, '/p,' + page + ',$1'); } async fetchDom(path: string): Promise { - const res = await fetchApi(this.site + path); + const res = await fetchApi(this.site + path, { headers: HEADERS }); if (!res.ok) { throw Object.assign(new Error(`HTTP error for ${path}`), { status: res.status, @@ -202,7 +290,7 @@ class OnlineReadFreeNovelPlugin implements Plugin.PluginBase { } async fetchPageDom(path: string): Promise { - const res = await fetchApi(this.site + path); + const res = await fetchApi(this.site + path, { headers: HEADERS }); return res.ok ? loadCheerio(await res.text()) : null; } From 8860ad6d06d74d6a0f988dea45d5b4c3855dc804 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Fri, 16 Oct 2026 06:32:11 +0800 Subject: [PATCH 8/8] no-mistakes(review): retry and throw on failed pages, warn only on real cap cutoff --- plugins/english/onlinereadfreenovel.ts | 36 +++++++++++++++++++++----- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/plugins/english/onlinereadfreenovel.ts b/plugins/english/onlinereadfreenovel.ts index e2042c228..1bc5c0937 100644 --- a/plugins/english/onlinereadfreenovel.ts +++ b/plugins/english/onlinereadfreenovel.ts @@ -34,6 +34,10 @@ const MAX_PAGES = 400; // took about 90 seconds for a 240-page book. const PAGE_BATCH = 6; +// Retries of a chapter-list page after a rate limit, server error or network +// error, waiting 1s, 2s, 4s between them. +const PAGE_RETRIES = 3; + // Empty pages in a row after a page that links to a next one before the walk // gives up. It only ends the walk when the book's real last page is empty. const MAX_EMPTY_RUN = 12; @@ -198,7 +202,8 @@ class OnlineReadFreeNovelPlugin implements Plugin.PluginBase { * is `p,,` inserted before the id. Pages are fetched PAGE_BATCH at a time * and handled in order. The walk ends at the first page with text and no * next link, or after MAX_EMPTY_RUN empty pages in a row. A page answered - * with no text or a non-OK status counts as empty and is skipped. + * with no text or a 404 counts as empty and is skipped. Any other failure + * throws after retries, so a rate limit never shortens the list silently. */ async parsePages( novelPath: string, @@ -208,6 +213,7 @@ class OnlineReadFreeNovelPlugin implements Plugin.PluginBase { // A path not in the `_read.html` form cannot be paged; list page 1 only. const lastPage = this.pagePath(novelPath, 2) === novelPath ? 1 : MAX_PAGES; let emptyRun = 0; + let hasNext = false; for (let start = 1; start <= lastPage; start += PAGE_BATCH) { const pages: number[] = []; @@ -218,14 +224,18 @@ class OnlineReadFreeNovelPlugin implements Plugin.PluginBase { pages.map(page => page === 1 ? firstPage - : this.fetchPageDom(this.pagePath(novelPath, page)), + : this.fetchPageDom(this.pagePath(novelPath, page)).catch( + (error: Error) => error, + ), ), ); for (let i = 0; i < pages.length; i++) { const $ = doms[i]; + if ($ instanceof Error) throw $; const paragraphs = $ ? this.paragraphs($) : []; if (!$ || paragraphs.length === 0) { + hasNext = false; if (++emptyRun >= MAX_EMPTY_RUN) return chapters; continue; } @@ -241,11 +251,12 @@ class OnlineReadFreeNovelPlugin implements Plugin.PluginBase { path: this.pagePath(novelPath, pages[i]), chapterNumber: chapters.length + 1, }); - if ($('.page_next a').length === 0) return chapters; + hasNext = $('.page_next a').length > 0; + if (!hasNext) return chapters; } } - if (lastPage === MAX_PAGES) { + if (lastPage === MAX_PAGES && hasNext) { console.warn( `onlinereadfreenovel: stopped at MAX_PAGES (${MAX_PAGES}) for ${novelPath}; remaining pages were not listed`, ); @@ -290,8 +301,21 @@ class OnlineReadFreeNovelPlugin implements Plugin.PluginBase { } async fetchPageDom(path: string): Promise { - const res = await fetchApi(this.site + path, { headers: HEADERS }); - return res.ok ? loadCheerio(await res.text()) : null; + for (let attempt = 0; ; attempt++) { + try { + const res = await fetchApi(this.site + path, { headers: HEADERS }); + if (res.status === 404) return null; + if (!res.ok) { + throw Object.assign(new Error(`HTTP error for ${path}`), { + status: res.status, + }); + } + return loadCheerio(await res.text()); + } catch (error) { + if (attempt >= PAGE_RETRIES) throw error; + await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** attempt)); + } + } } toPath(href: string): string {