Dreamy translations pr (#2405)

Co-authored-by: Rajarshee Chatterjee <rajarshee.adm@gmail.com>
This commit is contained in:
godilau008
2026-08-16 01:48:45 -04:00
committed by GitHub
parent 5cfcff6bec
commit 9fbb41bd4b
+543 -76
View File
@@ -49,11 +49,12 @@ class DreamyTranslationsPlugin implements Plugin.PluginBase {
name = 'Dreamy Translations'; name = 'Dreamy Translations';
icon = 'src/en/dreamyTranslations/icon.png'; icon = 'src/en/dreamyTranslations/icon.png';
site = 'https://dreamy-translations.com'; site = 'https://dreamy-translations.com';
version = '1.0.0'; version = '1.0.1';
filters: Filters | undefined = undefined; filters: Filters | undefined = undefined;
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined; imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
webStorageUtilized?: boolean; webStorageUtilized?: boolean;
private headers = { private headers = {
'User-Agent': 'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36',
@@ -66,96 +67,475 @@ class DreamyTranslationsPlugin implements Plugin.PluginBase {
}; };
/** /**
* This site is a Next.js app whose pages render their novel/chapter data * Dreamy Translations is a Next.js application.
* client-side; the plain HTML response is just a loading skeleton. Requesting *
* the same URL with the `RSC` header returns Next.js's React Server Component * Normal page requests return a loading shell, while requests
* "flight" stream instead, which carries the real data as a series of * containing the RSC header return a React Server Component
* `<id>:<value>` lines. Most lines are directly JSON-parsable once the leading * Flight response containing the actual page data.
* `<id>:` is stripped; long text bodies are instead referenced elsewhere as
* `"$<id>"` and streamed separately as a `<id>:T<hexByteLength>,<rawText>` line.
*/ */
private async fetchRsc(url: string): Promise<string> { private async fetchRsc(url: string): Promise<string> {
const res = await fetchApi(url, { headers: this.headers }); const res = await fetchApi(url, {
headers: this.headers,
});
return await res.text(); return await res.text();
} }
private extractRscObject<T>(rscText: string, marker: string): T { /**
const line = rscText.split('\n').find(l => l.includes(marker)); * Extract an object from a normal JSON RSC record.
*/
private extractRscObject<T>(
rscText: string,
marker: string,
): T {
const line = rscText
.split('\n')
.find(l => l.includes(marker));
if (!line) { if (!line) {
throw new Error('Could not locate expected data in server response'); throw new Error(
`Could not find "${marker}" in Dreamy Translations RSC response`,
);
} }
const jsonStr = line.slice(line.indexOf(':') + 1);
const colonIndex = line.indexOf(':');
if (colonIndex === -1) {
throw new Error(
'Invalid Dreamy Translations RSC record',
);
}
const jsonStr = line.slice(colonIndex + 1);
const parsed = JSON.parse(jsonStr); const parsed = JSON.parse(jsonStr);
return parsed[3] as T;
/*
* Next.js Flight records commonly contain the actual
* application data at index 3.
*/
if (
Array.isArray(parsed) &&
parsed.length > 3
) {
return parsed[3] as T;
}
return parsed as T;
} }
/** /**
* Text bodies aren't inline JSON: they're declared with their exact UTF-8 * Extract a streamed text record.
* byte length (`T<hex>,`) and the following chunk starts immediately after *
* those bytes with no separator, so byte-accurate slicing is required. * RSC text records look like:
*
* 123:T1a,<raw text>
*
* The length is expressed as UTF-8 byte length, so the
* response must be sliced by encoded bytes rather than
* JavaScript string characters.
*/ */
private extractDeferredText(rscText: string, refId: string): string { private extractDeferredText(
const match = new RegExp(`(?:^|\\n)${refId}:T([0-9a-f]+),`).exec(rscText); rscText: string,
refId: string,
): string {
const escapedId = refId.replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&',
);
const match = new RegExp(
`(?:^|\\n)${escapedId}:T([0-9a-fA-F]+),`,
).exec(rscText);
if (!match) { if (!match) {
throw new Error('Could not locate chapter content in server response'); throw new Error(
'Could not locate chapter content in server response',
);
} }
const start = match.index + match[0].length;
const byteLength = parseInt(match[1], 16); const start =
match.index + match[0].length;
const byteLength = parseInt(
match[1],
16,
);
const rest = rscText.slice(start); const rest = rscText.slice(start);
const bytes = new TextEncoder().encode(rest).slice(0, byteLength);
return new TextDecoder().decode(bytes); const bytes = new TextEncoder().encode(
rest,
);
return new TextDecoder().decode(
bytes.slice(0, byteLength),
);
} }
private async fetchAllNovels(): Promise<Plugin.NovelItem[]> { /**
const rscText = await this.fetchRsc(`${this.site}/series`); * Find a streamed text record.
const data = this.extractRscObject<SeriesListData>(rscText, '"projects"'); *
* This is a non-throwing version of extractDeferredText()
* used when chapter content may already be inline.
*/
private findDeferredText(
rscText: string,
refId: string,
): string | undefined {
const escapedId = refId.replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&',
);
return data.projects.map(project => ({ const match = new RegExp(
name: project.title, `(?:^|\\n)${escapedId}:T([0-9a-fA-F]+),`,
path: `/novel/${project.slug}`, ).exec(rscText);
cover: data.squareImageUrls[String(project.id)] || defaultCover,
})); if (!match) {
return undefined;
}
const start =
match.index + match[0].length;
const byteLength = parseInt(
match[1],
16,
);
const bytes = new TextEncoder().encode(
rscText.slice(start),
);
return new TextDecoder().decode(
bytes.slice(0, byteLength),
);
} }
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> { /**
if (pageNo !== 1) return []; * Chapter-specific RSC extraction.
*
* Chapter responses are not always represented by a
* directly JSON.parse()-able record. We therefore:
*
* 1. Try the normal RSC record format.
* 2. Search for the chapter object directly.
* 3. Parse a balanced JSON object.
* 4. Fall back to other JSON-looking Flight records.
*/
private extractChapterObject(
rscText: string,
): ChapterDetailData {
const lines = rscText.split('\n');
/*
* First attempt: normal Flight record.
*/
const chapterLine = lines.find(
line =>
line.includes('"chapter"') &&
line.includes('"hasAccess"'),
);
if (chapterLine) {
const colonIndex =
chapterLine.indexOf(':');
if (colonIndex !== -1) {
const payload =
chapterLine.slice(
colonIndex + 1,
);
try {
const parsed =
JSON.parse(payload);
if (
Array.isArray(parsed) &&
parsed.length > 3 &&
parsed[3]
) {
return parsed[3] as ChapterDetailData;
}
if (
parsed &&
typeof parsed === 'object' &&
!Array.isArray(parsed)
) {
return parsed as ChapterDetailData;
}
} catch {
/*
* The record isn't directly JSON.
* Continue with the fallbacks.
*/
}
}
}
/*
* Second attempt: locate the chapter object
* directly inside the Flight response.
*/
const marker = '"chapter":{';
const markerIndex =
rscText.indexOf(marker);
if (markerIndex === -1) {
throw new Error(
'Could not locate chapter data in server response',
);
}
let start = -1;
for (
let i = markerIndex;
i >= 0;
i--
) {
if (rscText[i] !== '{') {
continue;
}
const candidate =
rscText.slice(
i,
markerIndex + marker.length,
);
if (
candidate.startsWith('{') &&
candidate.includes('"chapter"')
) {
start = i;
break;
}
}
if (start === -1) {
throw new Error(
'Could not locate chapter object in server response',
);
}
/*
* Find the matching closing brace while
* respecting quoted strings and escapes.
*/
let depth = 0;
let inString = false;
let escaped = false;
for (
let i = start;
i < rscText.length;
i++
) {
const char = rscText[i];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === '\\') {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
continue;
}
if (char === '{') {
depth++;
} else if (char === '}') {
depth--;
if (depth === 0) {
const json =
rscText.slice(
start,
i + 1,
);
try {
return JSON.parse(
json,
) as ChapterDetailData;
} catch {
break;
}
}
}
}
/*
* Final fallback: inspect JSON-looking
* Flight records.
*/
for (const line of lines) {
const colon =
line.indexOf(':');
if (colon === -1) {
continue;
}
const payload =
line.slice(colon + 1);
if (
!payload.startsWith('[') &&
!payload.startsWith('{')
) {
continue;
}
try {
const parsed =
JSON.parse(payload);
if (Array.isArray(parsed)) {
for (const value of parsed) {
if (
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
'chapter' in value
) {
return value as ChapterDetailData;
}
}
}
if (
parsed &&
typeof parsed === 'object' &&
!Array.isArray(parsed) &&
'chapter' in parsed
) {
return parsed as ChapterDetailData;
}
} catch {
// Ignore unrelated RSC records.
}
}
throw new Error(
'Could not parse chapter data from Dreamy Translations response',
);
}
/**
* Fetch every novel from the series page.
*/
private async fetchAllNovels(): Promise<
Plugin.NovelItem[]
> {
const rscText =
await this.fetchRsc(
`${this.site}/series`,
);
const data =
this.extractRscObject<SeriesListData>(
rscText,
'"projects"',
);
return data.projects.map(
project => ({
name: project.title,
path: `/novel/${project.slug}`,
cover:
data.squareImageUrls[
String(project.id)
] || defaultCover,
}),
);
}
async popularNovels(
pageNo: number,
): Promise<Plugin.NovelItem[]> {
if (pageNo !== 1) {
return [];
}
return this.fetchAllNovels(); return this.fetchAllNovels();
} }
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> { async parseNovel(
const rscText = await this.fetchRsc(`${this.site}${novelPath}`); novelPath: string,
const data = this.extractRscObject<NovelDetailData>( ): Promise<Plugin.SourceNovel> {
rscText, const rscText =
'"chapters":[', await this.fetchRsc(
); `${this.site}${novelPath}`,
);
const data =
this.extractRscObject<NovelDetailData>(
rscText,
'"chapters":[',
);
const novel: Plugin.SourceNovel = { const novel: Plugin.SourceNovel = {
path: novelPath, path: novelPath,
name: data.project.title || 'Untitled', name:
cover: data.coverUrl || defaultCover, data.project.title ||
author: data.project.author, 'Untitled',
genres: (data.project.genres || []).join(', '), cover:
summary: data.project.synopsis || data.project.short_synopsis, data.coverUrl ||
status: data.project.completed defaultCover,
? NovelStatus.Completed author:
: NovelStatus.Ongoing, data.project.author,
genres:
(data.project.genres || [])
.join(', '),
summary:
data.project.synopsis ||
data.project.short_synopsis,
status:
data.project.completed
? NovelStatus.Completed
: NovelStatus.Ongoing,
}; };
novel.chapters = data.chapters.map(chapter => ({ novel.chapters =
name: chapter.free ? chapter.title : `🔒 ${chapter.title}`, data.chapters.map(
path: `${novelPath}/chapter/${chapter.index}`, chapter => ({
chapterNumber: chapter.index, name: chapter.free
})); ? chapter.title
: `🔒 ${chapter.title}`,
path:
`${novelPath}/chapter/${chapter.index}`,
chapterNumber:
chapter.index,
}),
);
return novel; return novel;
} }
async parseChapter(chapterPath: string): Promise<string> { async parseChapter(
const rscText = await this.fetchRsc(`${this.site}${chapterPath}`); chapterPath: string,
const data = this.extractRscObject<ChapterDetailData>( ): Promise<string> {
rscText, const rscText =
'"chapter":{', await this.fetchRsc(
); `${this.site}${chapterPath}`,
);
const data =
this.extractChapterObject(
rscText,
);
if (!data.hasAccess) { if (!data.hasAccess) {
throw new Error( throw new Error(
@@ -163,22 +543,98 @@ class DreamyTranslationsPlugin implements Plugin.PluginBase {
); );
} }
const refMatch = /^\$([0-9a-zA-Z]+)$/.exec(data.chapter.content); let content =
if (!refMatch) { data.chapter.content;
// Content was inlined directly rather than streamed separately.
return `<p>${data.chapter.content}</p>`; /*
* Chapter content may be a reference to
* a streamed RSC text record.
*/
const refMatch =
typeof content === 'string'
? /^\$([0-9a-zA-Z]+)$/.exec(
content,
)
: null;
if (refMatch) {
const streamed =
this.findDeferredText(
rscText,
refMatch[1],
);
if (
streamed !== undefined
) {
content = streamed;
}
} }
const rawText = this.extractDeferredText(rscText, refMatch[1]).replace( if (
/\r\n/g, !content ||
'\n', !content.trim()
); ) {
throw new Error(
'Dreamy Translations returned an empty chapter',
);
}
return rawText /*
* Normalize line endings.
*
* IMPORTANT:
* We intentionally do NOT HTML-escape the content.
* Dreamy supplies actual HTML, including <img> tags.
*/
const normalized =
content
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
/*
* Dreamy currently returns image src values
* in this form:
*
* src="[https://example.com/image](https://example.com/image)"
*
* Convert that into:
*
* src="https://example.com/image"
*/
.replace(
/(\bsrc\s*=\s*["'])\[([^\]]+)\]\(\2\)(["'])/gi,
'$1$2$3',
);
return normalized
.split(/\n{2,}/) .split(/\n{2,}/)
.map(paragraph => paragraph.trim()) .map(
paragraph =>
paragraph.trim(),
)
.filter(Boolean) .filter(Boolean)
.map(paragraph => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`) .map(paragraph => {
/*
* Don't wrap standalone images in <p>.
*/
if (
/^<img\b[^>]*\/?>$/i.test(
paragraph,
)
) {
return paragraph;
}
/*
* Preserve Dreamy's HTML while
* converting ordinary newlines to
* reader line breaks.
*/
return `<p>${paragraph.replace(
/\n/g,
'<br>',
)}</p>`;
})
.join(''); .join('');
} }
@@ -186,15 +642,26 @@ class DreamyTranslationsPlugin implements Plugin.PluginBase {
searchTerm: string, searchTerm: string,
pageNo: number, pageNo: number,
): Promise<Plugin.NovelItem[]> { ): Promise<Plugin.NovelItem[]> {
if (pageNo !== 1) return []; if (pageNo !== 1) {
return [];
}
const novels = await this.fetchAllNovels(); const novels =
const term = searchTerm.toLowerCase(); await this.fetchAllNovels();
return novels.filter(novel => novel.name.toLowerCase().includes(term)); const term =
searchTerm.toLowerCase();
return novels.filter(
novel =>
novel.name
.toLowerCase()
.includes(term),
);
} }
resolveUrl = (path: string) => this.site + path; resolveUrl = (path: string) =>
this.site + path;
} }
export default new DreamyTranslationsPlugin(); export default new DreamyTranslationsPlugin();