From 78ab06d9404f0c98549440565a24386c6292603f Mon Sep 17 00:00:00 2001 From: RibatTRW Date: Tue, 25 Aug 2026 11:53:42 +0800 Subject: [PATCH 1/4] fix: restore Riwyat covers and chapter content (#2465) cenele.com migrated to a custom Madara child theme (nhv-*) that dropped the classic selectors. parseChapter's unguarded Cheerio `||` chain short-circuited to an empty selection (truthy) returning '', and parseNovel lost its name/cover/metadata selectors. - parseChapter: length-guarded container chain (.text-left -> .text-right -> .reading-content -> .entry-content -> .c-blog-post...) with loud throws (container not found / content empty) instead of returning '' (#2462 pattern) - parseNovel: h1.nhv-novel-title and img.wp-post-image fallbacks - parseNovel: existence-guarded nhv-* metadata block (genres/status/author) - riwyat: versionIncrements 1 (plugin 2.2.1) and new customJs stripping [data-nosnippet]/style/script/promo injection - tests: regression-2465.mjs + fixtures (14 checks; RED 7 -> GREEN 14) Generated multisrc files are gitignored; run node plugins/multisrc/generate-multisrc-plugins.js before testing. --- plugins/multisrc/madara/sources.json | 3 +- plugins/multisrc/madara/template.ts | 52 +- tests/fixtures/2465/ch_noUA.html | 5122 ++++++++++++++++++++++++++ tests/fixtures/2465/chapters.json | 70 + tests/fixtures/2465/novel.html | 3504 ++++++++++++++++++ tests/regression-2465.mjs | 195 + 6 files changed, 8940 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/2465/ch_noUA.html create mode 100644 tests/fixtures/2465/chapters.json create mode 100644 tests/fixtures/2465/novel.html create mode 100644 tests/regression-2465.mjs diff --git a/plugins/multisrc/madara/sources.json b/plugins/multisrc/madara/sources.json index 5337c58b2..1f6067374 100644 --- a/plugins/multisrc/madara/sources.json +++ b/plugins/multisrc/madara/sources.json @@ -137,7 +137,8 @@ "options": { "useNewChapterEndpoint": true, "lang": "Arabic", - "customJs": "chapterText.find('span[style*=\"opacity: 0; position: fixed;\"],[role=\"presentation\"]').remove();" + "versionIncrements": 1, + "customJs": "chapterText.find('[data-nosnippet], style, script, input, .nhv-reader-store-promo').remove();" } }, { diff --git a/plugins/multisrc/madara/template.ts b/plugins/multisrc/madara/template.ts index 91b01be6c..aa9083cbc 100644 --- a/plugins/multisrc/madara/template.ts +++ b/plugins/multisrc/madara/template.ts @@ -189,6 +189,7 @@ export class MadaraPlugin implements Plugin.PluginBase { loadedCheerio('.post-title h1').text().trim() || loadedCheerio('#manga-title h1').text().trim() || loadedCheerio('.manga-title').text().trim() || + loadedCheerio('h1.nhv-novel-title').text().trim() || '', }; @@ -196,6 +197,7 @@ export class MadaraPlugin implements Plugin.PluginBase { loadedCheerio('.summary_image > a > img').attr('data-lazy-src') || loadedCheerio('.summary_image > a > img').attr('data-src') || loadedCheerio('.summary_image > a > img').attr('src') || + loadedCheerio('img.wp-post-image').attr('src') || defaultCover; loadedCheerio('.post-content_item, .post-content').each(function () { @@ -270,6 +272,30 @@ export class MadaraPlugin implements Plugin.PluginBase { ); } + // Checks for the "novel" Madara child theme (nhv-*, e.g. Riwyat / cenele.com). + // Existence-guarded: only applies when this theme's elements are actually on + // the page, so classic Madara/NovelHub sites are untouched. + { + const nhvStatus = loadedCheerio('.nhv-novel-status'); + if (nhvStatus.length > 0) + novel.status = nhvStatus.text().includes('مستمرة') + ? NovelStatus.Ongoing + : NovelStatus.Completed; + + const nhvGenres = loadedCheerio('.nhv-novel-genres'); + if (nhvGenres.length > 0) + novel.genres = nhvGenres + .find('a') + .map((i, el) => loadedCheerio(el).text().trim()) + .get() + .join(', '); + + const nhvAuthors = loadedCheerio( + '.nhv-novel-meta a[href*="cont-author"]', + ); + if (nhvAuthors.length > 0) novel.author = nhvAuthors.text().trim(); + } + if (!novel.author) novel.author = loadedCheerio('.manga-authors').text().trim(); @@ -386,10 +412,22 @@ export class MadaraPlugin implements Plugin.PluginBase { async parseChapter(chapterPath: string): Promise { const loadedCheerio = await this.getCheerio(this.site + chapterPath, false); const chapterText = - loadedCheerio('.text-left') || - loadedCheerio('.text-right') || - loadedCheerio('.entry-content') || - loadedCheerio('.c-blog-post > div > div:nth-child(2)'); + (loadedCheerio('.text-left').length > 0 && loadedCheerio('.text-left')) || + (loadedCheerio('.text-right').length > 0 && + loadedCheerio('.text-right')) || + (loadedCheerio('.reading-content').length > 0 && + loadedCheerio('.reading-content')) || + (loadedCheerio('.entry-content').length > 0 && + loadedCheerio('.entry-content')) || + (loadedCheerio('.c-blog-post > div > div:nth-child(2)').length > 0 && + loadedCheerio('.c-blog-post > div > div:nth-child(2)')) || + null; + + if (!chapterText || chapterText.length === 0) { + throw new Error( + `Chapter content container not found for ${chapterPath} — site layout may have changed; retry`, + ); + } if (this.options?.customJs) { try { @@ -400,7 +438,11 @@ export class MadaraPlugin implements Plugin.PluginBase { } } - return this.translateDragontea(chapterText).html() || ''; + const html = this.translateDragontea(chapterText).html(); + if (!html || html.trim().length === 0) { + throw new Error(`Chapter content empty after parsing for ${chapterPath}`); + } + return html; } async searchNovels( diff --git a/tests/fixtures/2465/ch_noUA.html b/tests/fixtures/2465/ch_noUA.html new file mode 100644 index 000000000..95edf9354 --- /dev/null +++ b/tests/fixtures/2465/ch_noUA.html @@ -0,0 +1,5122 @@ + + + + + + + + + + + + + + + الخلود الملعون - ما بعد الفناء - الفصل 3 - فضاء الروايات + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+
+
+
+
+ +
+
+ تنويه: + جميع ما يتم ترجمته من فصول منسوب للمؤلف، ونحن غير مسؤولين عن أي معانٍ تخالف العقيدة أو تتضمن معاني كفرية أو شركية. هذا المحتوى ترفيهي فقط، فلا تدعه يؤثر عليك أو على دينك. استغفر الله وأتوب إليه.
+ +
+
+
+ + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + + + +
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/fixtures/2465/chapters.json b/tests/fixtures/2465/chapters.json new file mode 100644 index 000000000..9b83a0079 --- /dev/null +++ b/tests/fixtures/2465/chapters.json @@ -0,0 +1,70 @@ +
+
+ Latest Release : + الفصل 3 +
+
+ 2026-08-24 21:31:36 +
+ +
+
+
+ + + + + + + +
+ + Show more +
+ +
+
diff --git a/tests/fixtures/2465/novel.html b/tests/fixtures/2465/novel.html new file mode 100644 index 000000000..d87347e71 --- /dev/null +++ b/tests/fixtures/2465/novel.html @@ -0,0 +1,3504 @@ + + + + + + + + + + + + الخلود الملعون – فضاء الروايات + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+
+
+
+ الخلود الملعون
+ +
+
+ Cursed Immortality + +

الخلود الملعون

+ + +
+ +
+
النوعإنجليزية
+
الحالةمستمرة
+
الفصول3
+
المشاهدات44
+
المؤلف
+
+ المترجم + + I'm not social +
+
+ +
+
+
+
+ +
+ + 0 + جوهرة في خزنة الرواية +
+
+
+ 0 + هذا الشهر +
+ +
+ +
+
+
+
+ + التقييم العام + 7.9 + / 10 + + + عدد المقيمين + 15 + + + تقييمك + غير محدد + +
+
+
+ + + + + + + + + + +
+
يجب تسجيل الدخول أو إنشاء حساب للتقييم
+
+
+
+ +
+ + ابدأ القراءةمن الفصل الأول + + +
إضافة للمفضلة
+ المفضلة + 0 قارئ + +
+
+
+ +
+
+
+ أحدث الإضافات +

آخر 8 فصول

+
+ عرض قائمة الفصول +
+ +
+ + +
+
+

قصة رواية الخلود الملعون

+
+

رواية الخلود الملعون

+

توفي جاكوب ستيف، وهو رجل مسن، عن عمر يناهز 116 عامًا بعد أن عاش حياة كاملة.

+

لكن يعقوب كان لديه دائماً جانب مظلم، كان يكبته ويخفيه عن الآخرين.

+

لكن في السنوات الأخيرة من حياته، عندما أصبح الموت أمراً لا مفر منه، أدرك أنه لا جدوى من امتلاك الثروة أو المعرفة أو الرغبات إذا لم يكن بإمكانك أن تعيش حياة تستمتع بها.

+

لقد أصبح مهووساً بالخلود الأسطوري وكرس ما تبقى من حياته لإطالة عمره الزائل، لكنه فشل كأي شخص آخر.

+

ومع ذلك، حصل بطريقة غامضة على فرصة أخرى في الحياة ووجد طريقة أدت إلى الخلود الأسطوري.

+

لكنه حُذِّر أيضاً من أن الطريق المؤدي إليه مليء بالدماء والظلام!

+

في النهاية، اختار يعقوب ذلك على أي حال دون وجود طريقة للعودة، وكانت هذه أيضًا بداية معاناته ورحلته البشعة والوحشية نحو “الخلود” الذي كان يريده بشدة!

+

رواية Cursed Immortality

+
+
+
+ +
+ هدية للمترجم + عبّر عن دعمك لهذه الرواية +

تدخل هديتك خزينة الرواية ويحصل مترجمها على قيمتها كاملة.

+
+
+ + +
+ +
+
+
+ +
+ + + + + + + +
+ + + + + +
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/regression-2465.mjs b/tests/regression-2465.mjs new file mode 100644 index 000000000..8e22d5e0a --- /dev/null +++ b/tests/regression-2465.mjs @@ -0,0 +1,195 @@ +// Regression test for lnreader/lnreader-plugins#2465 (Riwyat @ cenele.com: +// no covers / empty chapters). Deterministic: the REAL generated plugin code +// (bundled exactly like scripts/live-check-plugin.js) runs against SAVED +// live-site fixtures via a URL-matched global fetch stub — no network. +// +// Run: node plugins/multisrc/generate-multisrc-plugins.js (once, if the +// generated plugins/arabic/Riwyat[madara].ts is missing) +// Run: node tests/regression-2465.mjs +// +// Expected BEFORE the spec-2465 fix: N1 N2 N4 N5 N6 C1 T1 fail (RED). +// Expected AFTER the spec-2465 fix: all checks pass (GREEN). +import * as esbuild from 'esbuild'; +import path, { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; +import fs from 'fs'; +import { load } from 'cheerio'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..'); +const FIXTURES = path.resolve(__dirname, 'fixtures', '2465'); + +const fixtures = { + novel: fs.readFileSync(path.join(FIXTURES, 'novel.html'), 'utf8'), // /cont/cursed-imm/ detail page + chapters: fs.readFileSync(path.join(FIXTURES, 'chapters.json'), 'utf8'), // ajax/chapters POST payload + chapter: fs.readFileSync(path.join(FIXTURES, 'ch_noUA.html'), 'utf8'), // chapter page (no UA) + nocontent: + 'Test page
no chapter container here
', +}; + +let failures = 0; +const check = (label, cond, detail) => { + const ok = !!cond; + console.log(`${ok ? 'PASS' : 'FAIL'} ${label} ${ok ? '' : '-- ' + detail}`); + if (!ok) failures++; +}; +const expectReject = async (label, promise, pattern) => { + try { + await promise; + check(label, false, 'resolved but should have rejected'); + } catch (err) { + check( + label, + pattern.test(String((err && err.message) || err)), + `rejected without ${pattern}`, + ); + } +}; + +// --- Load the real generated plugin (identical bundling to scripts/live-check-plugin.js) --- +const absPath = path.resolve(REPO_ROOT, 'plugins/arabic/Riwyat[madara].ts'); +if (!fs.existsSync(absPath)) { + console.error( + `FAIL: generated plugin not found at ${absPath}\n` + + `Run "node plugins/multisrc/generate-multisrc-plugins.js" from the repo root first.`, + ); + process.exit(1); +} +const result = await esbuild.build({ + entryPoints: [absPath], + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node22', + write: false, + logLevel: 'silent', + alias: { + '@libs': path.join(REPO_ROOT, 'src/libs'), + '@': path.join(REPO_ROOT, 'src'), + }, +}); +const tmpFile = path.join(REPO_ROOT, 'regression-bundle.cjs'); +fs.writeFileSync(tmpFile, result.outputFiles[0].text, 'utf8'); +const require = createRequire(import.meta.url); +const mod = require(tmpFile); +const plugin = mod.default ?? mod; +fs.unlinkSync(tmpFile); + +// --- URL-matched fixture stub: the template talks to the site only via global fetch --- +globalThis.fetch = async (url, init) => { + const u = String(url); + let body; + if (u.includes('ajax/chapters')) body = fixtures.chapters; + else if (u.includes('cont/nocontent/')) body = fixtures.nocontent; + else if (u.endsWith('cont/cursed-imm/')) body = fixtures.novel; + else if (u.includes('cont/cursed-imm/')) + body = fixtures.chapter; // chapter pages + else throw new Error('No fixture for ' + u); + return { + ok: true, + status: 200, + url: u, + text: async () => body, + }; +}; + +// --- 0. Mechanism proof: empty Cheerio selection shadows later selectors --- +const $ = load(fixtures.chapter); +const tl = $('.text-left'); +const tr = $('.text-right'); +const ec = $('.entry-content'); +const cb = $('.c-blog-post > div > div:nth-child(2)'); +check( + 'selectors: .text-left absent in body', + tl.length === 0, + `got ${tl.length} matches`, +); +check( + 'mechanism: empty .text-left selection is TRUTHY (short-circuits || chain)', + Boolean(tl) === true, + 'empty Cheerio selection must be falsy-ish for the template chain to reach .entry-content', +); +const chainResult = tl || tr || ec || cb; +check( + 'mechanism: OLD unguarded || chain short-circuits to EMPTY .text-left selection (root cause trap)', + chainResult.length === 0, + `old chain picked ${chainResult.length} items instead of falling through to .entry-content`, +); + +// --- 1. parseNovel: name, cover, chapter list, genres, status, author --- +const novel = await plugin.parseNovel('cont/cursed-imm/'); +check( + 'parseNovel: novel name extracted', + novel.name === 'الخلود الملعون', + `got ${JSON.stringify(novel.name)}`, +); +check( + 'parseNovel: cover URL extracted from site (not defaultCover)', + typeof novel.cover === 'string' && + novel.cover.startsWith('https://cenele.com/wp-content/uploads'), + `got ${novel.cover}`, +); +check( + 'parseNovel: chapters listed', + Array.isArray(novel.chapters) && novel.chapters.length > 0, + `got ${novel?.chapters?.length} chapters`, +); +{ + const EXPECT_GENRES = + 'أكشن, بطل شرير, خيال, رعب, سحر, شريحة حياة, غموض, قوى خارقة, مظلمة, نفسي'; + check( + 'parseNovel: genres extracted (nhv-novel-genres)', + novel.genres === EXPECT_GENRES, + `got ${JSON.stringify(novel.genres)}`, + ); +} +check( + 'parseNovel: status Ongoing', + novel.status === 'Ongoing', + `got ${JSON.stringify(novel.status)}`, +); +check( + 'parseNovel: author extracted', + novel.author === "I'm not social", + `got ${JSON.stringify(novel.author)}`, +); + +// --- 2. parseChapter: content length + clean of promo spam on a real chapter page --- +const content = novel.chapters?.length + ? await plugin.parseChapter(novel.chapters[0].path) + : ''; +const len = (content || '').trim().length; +check( + 'parseChapter: content length >= 200', + len >= 200, + `got ${len} chars (user symptom: empty chapter)`, +); +const PROMO_MARKERS = ['آلاف الفصول', 'متجر فضاء الروايات', 'VIP']; +check( + 'parseChapter: no promo text markers', + PROMO_MARKERS.every(m => !(content || '').includes(m)), + `promo text leaked into chapter (${PROMO_MARKERS.filter(m => (content || '').includes(m)).join(', ')})`, +); +check( + 'parseChapter: no [data-nosnippet] in content', + (content || '').includes('data-nosnippet') === false, + 'nosnippet elements leaked into chapter', +); +check( + 'parseChapter: no inline style/script in content', + !(content || '').includes('/