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 {
|
class NovelUpdates implements Plugin.PluginBase {
|
||||||
id = 'novelupdates';
|
id = 'novelupdates';
|
||||||
name = 'Novel Updates';
|
name = 'Novel Updates';
|
||||||
version = '0.9.8';
|
version = '0.9.9';
|
||||||
icon = 'src/en/novelupdates/icon.png';
|
icon = 'src/en/novelupdates/icon.png';
|
||||||
customCSS = 'src/en/novelupdates/customCSS.css';
|
customCSS = 'src/en/novelupdates/customCSS.css';
|
||||||
site = 'https://www.novelupdates.com/';
|
site = 'https://www.novelupdates.com/';
|
||||||
@@ -513,6 +513,122 @@ class NovelUpdates implements Plugin.PluginBase {
|
|||||||
chapterContent = loadedCheerio('.entry-content').html()!;
|
chapterContent = loadedCheerio('.entry-content').html()!;
|
||||||
break;
|
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
|
// Last edited in 0.9.0 by Batorian - 19/03/2025
|
||||||
case 'novelplex': {
|
case 'novelplex': {
|
||||||
bloatElements = ['.passingthrough_adreminder'];
|
bloatElements = ['.passingthrough_adreminder'];
|
||||||
|
|||||||
Reference in New Issue
Block a user