From 0f7992ff18ecbac5b015e72a665cb831418b1f66 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 15 Oct 2026 19:53:10 +0800 Subject: [PATCH 1/4] feat(spanish/novels2): add novels2 source plugin Adds a standalone Spanish source plugin for novels2.com (issue #120): browse the latest listing, open a novel, read its full chapter list, read chapter text, and search. - The novel page only renders the first 20 chapters in its markup. The full list is serialized in the Next.js RSC payload, so it is parsed from the raw HTML (253 chapters on the longest checked novel). - Covers the site marks as failed (`*_failed.png`) fall back to the default cover; relative cover paths are resolved against the site. - No existing multisrc generator matches the site, so this is standalone. --- plugins/spanish/novels2.ts | 192 ++++++++++++++++++++++++++ public/static/src/es/novels2/icon.png | Bin 0 -> 4075 bytes 2 files changed, 192 insertions(+) create mode 100644 plugins/spanish/novels2.ts create mode 100644 public/static/src/es/novels2/icon.png diff --git a/plugins/spanish/novels2.ts b/plugins/spanish/novels2.ts new file mode 100644 index 000000000..b4181d94b --- /dev/null +++ b/plugins/spanish/novels2.ts @@ -0,0 +1,192 @@ +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { NovelStatus } from '@libs/novelStatus'; +import { defaultCover } from '@libs/defaultCover'; +import { load as loadCheerio } from 'cheerio'; + +class Novels2 implements Plugin.PluginBase { + id = 'novels2'; + name = 'Novels2'; + site = 'https://www.novels2.com'; + version = '1.0.0'; + icon = 'src/es/novels2/icon.png'; + + private async fetchHtml(url: string): Promise { + const res = await fetchApi(url); + if (!res.ok) { + throw new Error(`Could not reach site (${res.status})`); + } + return res.text(); + } + + /** + * Covers without an image are served as the site's `*_failed.png` + * placeholder, so those fall back to the default cover. Other paths can be + * relative, so they are resolved against the site. + */ + private toCover(src?: string): string { + if (!src || /_failed\.png$/.test(src)) { + return defaultCover; + } + return src.startsWith('http') ? src : `${this.site}${src}`; + } + + /** Novel cards share one markup on the listing and search pages. */ + private parseNovelCards(html: string): Plugin.NovelItem[] { + const $ = loadCheerio(html); + const novels: Plugin.NovelItem[] = []; + const seen: Record = {}; + + $('h3 a[href^="/novel/"]').each((_, el) => { + const path = $(el).attr('href') || ''; + if (!path || seen[path]) { + return; + } + seen[path] = true; + novels.push({ + name: $(el).text().trim(), + path, + cover: this.toCover($(`a.novel-cover[href="${path}"] img`).attr('src')), + }); + }); + + return novels; + } + + async popularNovels(pageNo: number): Promise { + const html = await this.fetchHtml(`${this.site}/?page=${pageNo}`); + return this.parseNovelCards(html); + } + + async parseNovel(novelPath: string): Promise { + const html = await this.fetchHtml(`${this.site}${novelPath}`); + const $ = loadCheerio(html); + + /** Value of a `
Label:Value` row. */ + const metaValue = (label: string) => { + let value = ''; + $('div.text-md').each((_, el) => { + const spans = $(el).children('span'); + if (spans.first().text().trim() === label) { + value = spans.eq(1).text().trim(); + } + }); + return value; + }; + + /** The block that follows an `

` heading such as Synopsis or Genre. */ + const sectionByHeading = (heading: string) => + $('h3') + .filter((_, el) => $(el).text().trim() === heading) + .first() + .parent(); + + const summary = sectionByHeading('Synopsis') + .find('p') + .map((_, el) => $(el).text().trim()) + .get() + .filter(Boolean) + .join('\n'); + + const genres = sectionByHeading('Genre') + .find('a') + .map((_, el) => $(el).text().trim()) + .get() + .join(', '); + + const status = metaValue('Status:').toLowerCase(); + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: $('h2.text-xl').first().text().trim(), + cover: this.toCover($('.novel-cover img').first().attr('src')), + author: metaValue('Author:') || undefined, + summary: summary || undefined, + genres: genres || undefined, + status: status.includes('ongoing') + ? NovelStatus.Ongoing + : status.includes('complet') + ? NovelStatus.Completed + : NovelStatus.Unknown, + }; + + novel.chapters = this.parseChapterList(html, novelPath); + return novel; + } + + /** + * The novel page renders only the first 20 chapters in its markup. The full + * list (every chapter, ascending by number) is serialized in the Next.js RSC + * payload as escaped JSON, so it is read from the raw HTML instead. + */ + private parseChapterList( + html: string, + novelPath: string, + ): Plugin.ChapterItem[] { + const chapters: Plugin.ChapterItem[] = []; + const chapterRe = + /\\"id\\":(\d+),\\"title\\":\\"((?:[^"\\]|\\.)*?)\\",\\"number\\":(\d+),\\"createdAt\\":\\"\$D([^"\\]+)\\"/g; + + let match = chapterRe.exec(html); + while (match !== null) { + const title = this.unescapeRsc(match[2]); + const slug = title + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .trim() + .replace(/\s+/g, '-'); + chapters.push({ + name: title, + path: `${novelPath}/${slug || 'chapter'}-${match[1]}`, + releaseTime: match[4], + chapterNumber: parseInt(match[3], 10), + }); + match = chapterRe.exec(html); + } + + return chapters; + } + + /** Undo the JSON string escaping used inside the RSC payload. */ + private unescapeRsc(value: string): string { + return value + .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) => + String.fromCharCode(parseInt(hex, 16)), + ) + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\'); + } + + async parseChapter(chapterPath: string): Promise { + const html = await this.fetchHtml(`${this.site}${chapterPath}`); + const $ = loadCheerio(html); + + const paragraphs = $('article p') + .map((_, el) => { + const text = $(el).text().trim(); + return text ? `

${$(el).html()}

` : ''; + }) + .get() + .filter(Boolean) + .join(''); + + if (!paragraphs) { + throw new Error('Chapter text not found'); + } + return paragraphs; + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + const html = await this.fetchHtml( + `${this.site}/search?keyword=${encodeURIComponent(searchTerm)}&page=${pageNo}`, + ); + return this.parseNovelCards(html); + } + + resolveUrl = (path: string): string => this.site + path; +} + +export default new Novels2(); diff --git a/public/static/src/es/novels2/icon.png b/public/static/src/es/novels2/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..227aff60e73f6ebc489edba02c0a9b052a714dca GIT binary patch literal 4075 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 J<$14!{{nz9em4LB literal 0 HcmV?d00001 From 285b2a6085a6e7ea0f534431155a48a73a12e763 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 15 Oct 2026 20:03:09 +0800 Subject: [PATCH 2/4] no-mistakes(review): Decode RSC titles in two passes; throw on unparsed chapter payload --- plugins/spanish/novels2.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/spanish/novels2.ts b/plugins/spanish/novels2.ts index b4181d94b..b92f2d4af 100644 --- a/plugins/spanish/novels2.ts +++ b/plugins/spanish/novels2.ts @@ -144,17 +144,17 @@ class Novels2 implements Plugin.PluginBase { match = chapterRe.exec(html); } + if (chapters.length === 0 && html.includes('\\"createdAt\\"')) { + throw new Error(`Could not parse chapter list for ${novelPath}`); + } + return chapters; } /** Undo the JSON string escaping used inside the RSC payload. */ private unescapeRsc(value: string): string { - return value - .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) => - String.fromCharCode(parseInt(hex, 16)), - ) - .replace(/\\"/g, '"') - .replace(/\\\\/g, '\\'); + const rowEscaped: string = JSON.parse(`"${value}"`); + return JSON.parse(`"${rowEscaped}"`); } async parseChapter(chapterPath: string): Promise { From ee2e26b0ebcc5708a0c0419999af369ab230da41 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 15 Oct 2026 21:49:59 +0800 Subject: [PATCH 3/4] no-mistakes(test): Throw on unparsed RSC chapter data without createdAt --- plugins/spanish/novels2.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/spanish/novels2.ts b/plugins/spanish/novels2.ts index b92f2d4af..d9fc49fe1 100644 --- a/plugins/spanish/novels2.ts +++ b/plugins/spanish/novels2.ts @@ -144,7 +144,7 @@ class Novels2 implements Plugin.PluginBase { match = chapterRe.exec(html); } - if (chapters.length === 0 && html.includes('\\"createdAt\\"')) { + if (chapters.length === 0 && /\\"number\\":\d/.test(html)) { throw new Error(`Could not parse chapter list for ${novelPath}`); } From a69380a3c15faa293646a539c95aefdef0c1bc21 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Fri, 16 Oct 2026 06:00:15 +0800 Subject: [PATCH 4/4] fix(spanish/novels2): keep absolute URLs unchanged in resolveUrl resolveUrl prepended the site to every path, so an absolute cover URL became https://www.novels2.comhttps://novels2.com/... and exported EPUB books lost their covers. Absolute http(s) URLs are now returned as-is; relative paths are still joined to the site. --- plugins/spanish/novels2.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/spanish/novels2.ts b/plugins/spanish/novels2.ts index d9fc49fe1..8a31cedea 100644 --- a/plugins/spanish/novels2.ts +++ b/plugins/spanish/novels2.ts @@ -186,7 +186,9 @@ class Novels2 implements Plugin.PluginBase { return this.parseNovelCards(html); } - resolveUrl = (path: string): string => this.site + path; + /** Absolute URLs (such as cover images) are returned as they are. */ + resolveUrl = (path: string): string => + /^https?:\/\//.test(path) ? path : this.site + path; } export default new Novels2();