Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions plugins/english/dreamyTranslations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { Filters } from '@libs/filterInputs';
import { defaultCover } from '@libs/defaultCover';
import { NovelStatus } from '@libs/novelStatus';

type SeriesProject = {
id: number;
title: string;
slug: string;
};

type SeriesListData = {
projects: SeriesProject[];
squareImageUrls: Record<string, string>;
};

type NovelChapter = {
id: number;
title: string;
index: number;
free: boolean;
};

type NovelDetailData = {
project: {
title: string;
synopsis?: string;
short_synopsis?: string;
author?: string;
genres?: string[];
completed?: boolean;
};
chapters: NovelChapter[];
coverUrl?: string;
};

type ChapterDetailData = {
chapter: {
title: string;
content: string;
free: boolean;
};
hasAccess: boolean;
};

class DreamyTranslationsPlugin implements Plugin.PluginBase {
id = 'dreamyTranslations';
name = 'Dreamy Translations';
icon = 'src/en/dreamyTranslations/icon.png';
site = 'https://dreamy-translations.com';
version = '1.0.0';
filters: Filters | undefined = undefined;
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;

webStorageUtilized?: boolean;
private headers = {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36',
Referer: this.site,
Accept: '*/*',
'Accept-Language': 'en-US,en;q=0.9',
'Cache-Control': 'no-cache',
Pragma: 'no-cache',
RSC: '1',
};

/**
* This site is a Next.js app whose pages render their novel/chapter data
* 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
* "flight" stream instead, which carries the real data as a series of
* `<id>:<value>` lines. Most lines are directly JSON-parsable once the leading
* `<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> {
const res = await fetchApi(url, { headers: this.headers });
return await res.text();
}

private extractRscObject<T>(rscText: string, marker: string): T {
const line = rscText.split('\n').find(l => l.includes(marker));
if (!line) {
throw new Error('Could not locate expected data in server response');
}
const jsonStr = line.slice(line.indexOf(':') + 1);
const parsed = JSON.parse(jsonStr);
return parsed[3] as T;
}

/**
* Text bodies aren't inline JSON: they're declared with their exact UTF-8
* byte length (`T<hex>,`) and the following chunk starts immediately after
* those bytes with no separator, so byte-accurate slicing is required.
*/
private extractDeferredText(rscText: string, refId: string): string {
const match = new RegExp(`(?:^|\\n)${refId}:T([0-9a-f]+),`).exec(rscText);
if (!match) {
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 rest = rscText.slice(start);
const bytes = new TextEncoder().encode(rest).slice(0, byteLength);
return new TextDecoder().decode(bytes);
}

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();
}

async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const rscText = await this.fetchRsc(`${this.site}${novelPath}`);
const data = this.extractRscObject<NovelDetailData>(
rscText,
'"chapters":[',
);

const novel: Plugin.SourceNovel = {
path: novelPath,
name: data.project.title || 'Untitled',
cover: data.coverUrl || defaultCover,
author: 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 => ({
name: chapter.free ? chapter.title : `🔒 ${chapter.title}`,
path: `${novelPath}/chapter/${chapter.index}`,
chapterNumber: chapter.index,
}));

return novel;
}

async parseChapter(chapterPath: string): Promise<string> {
const rscText = await this.fetchRsc(`${this.site}${chapterPath}`);
const data = this.extractRscObject<ChapterDetailData>(
rscText,
'"chapter":{',
);

if (!data.hasAccess) {
throw new Error(
'This chapter requires premium access and cannot be read here.',
);
}

const refMatch = /^\$([0-9a-zA-Z]+)$/.exec(data.chapter.content);
if (!refMatch) {
// Content was inlined directly rather than streamed separately.
return `<p>${data.chapter.content}</p>`;
}

const rawText = this.extractDeferredText(rscText, refMatch[1]).replace(
/\r\n/g,
'\n',
);

return rawText
.split(/\n{2,}/)
.map(paragraph => paragraph.trim())
.filter(Boolean)
.map(paragraph => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`)
.join('');
}

async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
if (pageNo !== 1) return [];

const novels = await this.fetchAllNovels();
const term = searchTerm.toLowerCase();

return novels.filter(novel => novel.name.toLowerCase().includes(term));
}

resolveUrl = (path: string) => this.site + path;
}

export default new DreamyTranslationsPlugin();
Binary file added public/static/src/en/dreamyTranslations/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.