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>
This commit is contained in:
@@ -6,7 +6,7 @@ import { Plugin } from '@/types/plugin';
|
||||
class NovelUpdates implements Plugin.PluginBase {
|
||||
id = 'novelupdates';
|
||||
name = 'Novel Updates';
|
||||
version = '0.9.8';
|
||||
version = '0.9.9';
|
||||
icon = 'src/en/novelupdates/icon.png';
|
||||
customCSS = 'src/en/novelupdates/customCSS.css';
|
||||
site = 'https://www.novelupdates.com/';
|
||||
@@ -513,6 +513,122 @@ class NovelUpdates implements Plugin.PluginBase {
|
||||
chapterContent = loadedCheerio('.entry-content').html()!;
|
||||
break;
|
||||
}
|
||||
// Last edited in 0.9.9 by Batorian - 09/05/2026
|
||||
case 'mythoriatales': {
|
||||
/**
|
||||
* Mythoria Tales uses Next.js Server Actions for chapter delivery.
|
||||
* The response is a 'text/x-component' stream (RSC).
|
||||
*
|
||||
* Payload Structure:
|
||||
* 0:{"a":"$@1",...} -> Initialization/Metadata
|
||||
* 2:T{hexLen},... -> Chapter Body (may span multiple segments)
|
||||
* 3:T{hexLen},... -> Chapter Body continuation (if split)
|
||||
* 1:{"success":...} -> Series/Chapter JSON metadata (authoritative title source)
|
||||
*/
|
||||
const html = loadedCheerio('script:contains("script-2")').html();
|
||||
if (!html) throw new Error('Failed to find script-2');
|
||||
const matches = Array.from(html.matchAll(/"script-2.*?[^_]+([^\\]+)/g));
|
||||
const scriptPath = matches[1]?.[1];
|
||||
if (!scriptPath) throw new Error('Failed to extract script-2 URL');
|
||||
|
||||
const scriptUrl = new URL(`/${scriptPath}`, chapterPath).href;
|
||||
const scriptText = await (await fetchApi(scriptUrl)).text();
|
||||
const ACTION_HASH = scriptText.match(/[a-f0-9]{42}/)?.[0];
|
||||
if (!ACTION_HASH) throw new Error('Failed to extract ACTION_HASH');
|
||||
|
||||
// chapterPath: https://www.mythoriatales.com/series/[slug]/chapter/[num]
|
||||
const urlParts = chapterPath.split('/');
|
||||
const [slug, chapterNum] = [urlParts[4], parseInt(urlParts[6], 10)];
|
||||
|
||||
const response = await fetchApi(chapterPath, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'text/x-component',
|
||||
'Content-Type': 'text/plain;charset=UTF-8',
|
||||
'next-action': ACTION_HASH,
|
||||
},
|
||||
body: JSON.stringify([slug, chapterNum]),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch chapter: ${response.status}`);
|
||||
}
|
||||
|
||||
const rscText = (await response.text()).replace(/(\d+:[{TE])/g, '\n$1');
|
||||
|
||||
/**
|
||||
* 1. Isolate the data segments.
|
||||
* We split by newline followed by a digit and a type marker ({, T, E).
|
||||
* Using a lookahead (?=...) ensures the split marker isn't consumed,
|
||||
* allowing us to verify the segment index (e.g., "2:").
|
||||
*/
|
||||
const segments = rscText.split(/\n(?=\d+:[{TE])/);
|
||||
|
||||
/**
|
||||
* 2. Locate and join all text content segments.
|
||||
* Some chapters split their body across multiple T-type segments (e.g., 2:T, 3:T).
|
||||
* We collect all of them (excluding the 0: init segment) and join into one string,
|
||||
* stripping each segment's "{index}:T{hexLen}," prefix in the process.
|
||||
*/
|
||||
const contentSegment = segments
|
||||
.filter(s => /^\d+:T/.test(s) && !s.startsWith('0:'))
|
||||
.map(s => s.replace(/^\d+:T[0-9a-f]+,/, ''))
|
||||
.join('');
|
||||
|
||||
if (!contentSegment) {
|
||||
throw new Error(
|
||||
'Could not find the chapter content segment (2:T) in the stream.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. Parse Lines and Paragraphs
|
||||
* Splits on literal newlines or escaped sequence "\n".
|
||||
* Filters out empty strings to handle double-spacing in the source.
|
||||
*/
|
||||
const lines = contentSegment
|
||||
.trim()
|
||||
.split(/(?:\r?\n|\\n)+/)
|
||||
.map((line: string) => line.trim())
|
||||
.filter((line: string) => line.length > 0);
|
||||
|
||||
if (lines.length === 0) {
|
||||
throw new Error('Parsed content is empty.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 4. Extract title from the "1:{...}" metadata segment.
|
||||
* This is the authoritative source for the chapter title and number,
|
||||
* preferred over parsing the first content line.
|
||||
*/
|
||||
const metaSegment = segments.find(s => s.startsWith('1:'));
|
||||
if (metaSegment) {
|
||||
try {
|
||||
const meta = JSON.parse(metaSegment.slice(2)); // strip leading "1:"
|
||||
const title = meta?.data?.chapter?.title;
|
||||
const num = meta?.data?.chapter?.chapterNumber ?? chapterNum;
|
||||
if (title) chapterTitle = `Chapter ${num}: ${title}`;
|
||||
} catch {
|
||||
// fall back to chapterNum if metadata parsing fails
|
||||
}
|
||||
}
|
||||
if (!chapterTitle) chapterTitle = `Chapter ${chapterNum}`;
|
||||
|
||||
// 5. All lines from the content segment are paragraphs.
|
||||
chapterContent = lines.map((p: string) => `<p>${p}</p>`).join('\n');
|
||||
|
||||
// Clean up custom markup tags:
|
||||
// Format [dialogue speaker="Name"]text[/dialogue] as "Name: text", drop [sfx] blocks entirely
|
||||
chapterContent = chapterContent
|
||||
.replace(
|
||||
/\[dialogue\s+speaker="([^"]*)"\](.*?)\[\/dialogue\]/gi,
|
||||
'$1: $2',
|
||||
)
|
||||
.replace(/\[sfx\].*?\[\/sfx\]/gi, '')
|
||||
.replace(/\[\/?(dialogue|sfx)[^\]]*\]/gi, '');
|
||||
|
||||
break;
|
||||
}
|
||||
// Last edited in 0.9.0 by Batorian - 19/03/2025
|
||||
case 'novelplex': {
|
||||
bloatElements = ['.passingthrough_adreminder'];
|
||||
|
||||
Reference in New Issue
Block a user