diff --git a/eslint.config.js b/eslint.config.js index 30c4da5df..fd1073d1f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -196,6 +196,14 @@ export default tseslint.config( }, }, }, + { + files: ['**/*.mjs'], + languageOptions: { + globals: { + ...globals.node, + }, + }, + }, { files: ['**/fictioneer/custom/*/*.js'], rules: { 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..46fb50f22 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..8d0bebb38 --- /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 => { + 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('/