@@ -6,7 +6,7 @@ import { Plugin } from '@/types/plugin';
66class 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 ( / " s c r i p t - 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 - f 0 - 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 + : [ { T E ] ) / 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 + : [ { T E ] ) / ) ;
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 - 9 a - 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+ / \[ d i a l o g u e \s + s p e a k e r = " ( [ ^ " ] * ) " \] ( .* ?) \[ \/ d i a l o g u e \] / gi,
625+ '$1: $2' ,
626+ )
627+ . replace ( / \[ s f x \] .* ?\[ \/ s f x \] / gi, '' )
628+ . replace ( / \[ \/ ? ( d i a l o g u e | s f x ) [ ^ \] ] * \] / 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