Skip to content

Commit 16b0dd5

Browse files
BatorianCopilotK1ngfish3r
authored
feat: Add MythoriaTales to Novel Updates (#2177)
* added mythoriatales * update Co-authored-by: Copilot <copilot@github.com> * remove chapterText Co-authored-by: Copilot <copilot@github.com> * Claude rewrite * fix metadata included * update chapterContent detection * fix * Gemini chapterContent rework * optimize chapterContent extraction * revert versions * get action hash via regex Co-authored-by: K1ngfish3r <26593485+K1ngfish3r@users.noreply.github.com> * title extraction from meta * fix * chapterContent cleanup * revert versions --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: K1ngfish3r <26593485+K1ngfish3r@users.noreply.github.com>
1 parent d161170 commit 16b0dd5

1 file changed

Lines changed: 117 additions & 1 deletion

File tree

plugins/english/novelupdates.ts

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Plugin } from '@/types/plugin';
66
class NovelUpdates implements Plugin.PluginBase {
77
id = 'novelupdates';
88
name = 'Novel Updates';
9-
version = '0.9.8';
9+
version = '0.9.9';
1010
icon = 'src/en/novelupdates/icon.png';
1111
customCSS = 'src/en/novelupdates/customCSS.css';
1212
site = 'https://www.novelupdates.com/';
@@ -513,6 +513,122 @@ class NovelUpdates implements Plugin.PluginBase {
513513
chapterContent = loadedCheerio('.entry-content').html()!;
514514
break;
515515
}
516+
// Last edited in 0.9.9 by Batorian - 09/05/2026
517+
case 'mythoriatales': {
518+
/**
519+
* Mythoria Tales uses Next.js Server Actions for chapter delivery.
520+
* The response is a 'text/x-component' stream (RSC).
521+
*
522+
* Payload Structure:
523+
* 0:{"a":"$@1",...} -> Initialization/Metadata
524+
* 2:T{hexLen},... -> Chapter Body (may span multiple segments)
525+
* 3:T{hexLen},... -> Chapter Body continuation (if split)
526+
* 1:{"success":...} -> Series/Chapter JSON metadata (authoritative title source)
527+
*/
528+
const html = loadedCheerio('script:contains("script-2")').html();
529+
if (!html) throw new Error('Failed to find script-2');
530+
const matches = Array.from(html.matchAll(/"script-2.*?[^_]+([^\\]+)/g));
531+
const scriptPath = matches[1]?.[1];
532+
if (!scriptPath) throw new Error('Failed to extract script-2 URL');
533+
534+
const scriptUrl = new URL(`/${scriptPath}`, chapterPath).href;
535+
const scriptText = await (await fetchApi(scriptUrl)).text();
536+
const ACTION_HASH = scriptText.match(/[a-f0-9]{42}/)?.[0];
537+
if (!ACTION_HASH) throw new Error('Failed to extract ACTION_HASH');
538+
539+
// chapterPath: https://www.mythoriatales.com/series/[slug]/chapter/[num]
540+
const urlParts = chapterPath.split('/');
541+
const [slug, chapterNum] = [urlParts[4], parseInt(urlParts[6], 10)];
542+
543+
const response = await fetchApi(chapterPath, {
544+
method: 'POST',
545+
headers: {
546+
'Accept': 'text/x-component',
547+
'Content-Type': 'text/plain;charset=UTF-8',
548+
'next-action': ACTION_HASH,
549+
},
550+
body: JSON.stringify([slug, chapterNum]),
551+
});
552+
553+
if (!response.ok) {
554+
throw new Error(`Failed to fetch chapter: ${response.status}`);
555+
}
556+
557+
const rscText = (await response.text()).replace(/(\d+:[{TE])/g, '\n$1');
558+
559+
/**
560+
* 1. Isolate the data segments.
561+
* We split by newline followed by a digit and a type marker ({, T, E).
562+
* Using a lookahead (?=...) ensures the split marker isn't consumed,
563+
* allowing us to verify the segment index (e.g., "2:").
564+
*/
565+
const segments = rscText.split(/\n(?=\d+:[{TE])/);
566+
567+
/**
568+
* 2. Locate and join all text content segments.
569+
* Some chapters split their body across multiple T-type segments (e.g., 2:T, 3:T).
570+
* We collect all of them (excluding the 0: init segment) and join into one string,
571+
* stripping each segment's "{index}:T{hexLen}," prefix in the process.
572+
*/
573+
const contentSegment = segments
574+
.filter(s => /^\d+:T/.test(s) && !s.startsWith('0:'))
575+
.map(s => s.replace(/^\d+:T[0-9a-f]+,/, ''))
576+
.join('');
577+
578+
if (!contentSegment) {
579+
throw new Error(
580+
'Could not find the chapter content segment (2:T) in the stream.',
581+
);
582+
}
583+
584+
/**
585+
* 3. Parse Lines and Paragraphs
586+
* Splits on literal newlines or escaped sequence "\n".
587+
* Filters out empty strings to handle double-spacing in the source.
588+
*/
589+
const lines = contentSegment
590+
.trim()
591+
.split(/(?:\r?\n|\\n)+/)
592+
.map((line: string) => line.trim())
593+
.filter((line: string) => line.length > 0);
594+
595+
if (lines.length === 0) {
596+
throw new Error('Parsed content is empty.');
597+
}
598+
599+
/**
600+
* 4. Extract title from the "1:{...}" metadata segment.
601+
* This is the authoritative source for the chapter title and number,
602+
* preferred over parsing the first content line.
603+
*/
604+
const metaSegment = segments.find(s => s.startsWith('1:'));
605+
if (metaSegment) {
606+
try {
607+
const meta = JSON.parse(metaSegment.slice(2)); // strip leading "1:"
608+
const title = meta?.data?.chapter?.title;
609+
const num = meta?.data?.chapter?.chapterNumber ?? chapterNum;
610+
if (title) chapterTitle = `Chapter ${num}: ${title}`;
611+
} catch {
612+
// fall back to chapterNum if metadata parsing fails
613+
}
614+
}
615+
if (!chapterTitle) chapterTitle = `Chapter ${chapterNum}`;
616+
617+
// 5. All lines from the content segment are paragraphs.
618+
chapterContent = lines.map((p: string) => `<p>${p}</p>`).join('\n');
619+
620+
// Clean up custom markup tags:
621+
// Format [dialogue speaker="Name"]text[/dialogue] as "Name: text", drop [sfx] blocks entirely
622+
chapterContent = chapterContent
623+
.replace(
624+
/\[dialogue\s+speaker="([^"]*)"\](.*?)\[\/dialogue\]/gi,
625+
'$1: $2',
626+
)
627+
.replace(/\[sfx\].*?\[\/sfx\]/gi, '')
628+
.replace(/\[\/?(dialogue|sfx)[^\]]*\]/gi, '');
629+
630+
break;
631+
}
516632
// Last edited in 0.9.0 by Batorian - 19/03/2025
517633
case 'novelplex': {
518634
bloatElements = ['.passingthrough_adreminder'];

0 commit comments

Comments
 (0)