diff --git a/plugins/english/dreamyTranslations.ts b/plugins/english/dreamyTranslations.ts new file mode 100644 index 000000000..a2c603757 --- /dev/null +++ b/plugins/english/dreamyTranslations.ts @@ -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; +}; + +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 + * `:` lines. Most lines are directly JSON-parsable once the leading + * `:` is stripped; long text bodies are instead referenced elsewhere as + * `"$"` and streamed separately as a `:T,` line. + */ + private async fetchRsc(url: string): Promise { + const res = await fetchApi(url, { headers: this.headers }); + return await res.text(); + } + + private extractRscObject(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,`) 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 { + const rscText = await this.fetchRsc(`${this.site}/series`); + const data = this.extractRscObject(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 { + if (pageNo !== 1) return []; + return this.fetchAllNovels(); + } + + async parseNovel(novelPath: string): Promise { + const rscText = await this.fetchRsc(`${this.site}${novelPath}`); + const data = this.extractRscObject( + 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 { + const rscText = await this.fetchRsc(`${this.site}${chapterPath}`); + const data = this.extractRscObject( + 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 `

${data.chapter.content}

`; + } + + 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 => `

${paragraph.replace(/\n/g, '
')}

`) + .join(''); + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + 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(); diff --git a/public/static/src/en/dreamyTranslations/icon.png b/public/static/src/en/dreamyTranslations/icon.png new file mode 100644 index 000000000..db670705b Binary files /dev/null and b/public/static/src/en/dreamyTranslations/icon.png differ