feat(dreamytranslation): add hide locked chapters setting (#2536)

Co-authored-by: Little Falcon <113622554+LittleFanwo@users.noreply.github.com>
This commit is contained in:
Danyon
2026-09-21 17:18:02 +06:30
committed by GitHub
parent ace5f655df
commit ccd3a0ebef
+117 -276
View File
@@ -49,12 +49,20 @@ 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.1'; version = '1.1.0';
filters: Filters | undefined = undefined; filters: Filters | undefined = undefined;
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined; imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
webStorageUtilized?: boolean; webStorageUtilized?: boolean;
pluginSettings = {
hideLocked: {
value: false,
label: 'Hide locked chapters',
type: 'Switch',
},
};
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',
@@ -84,13 +92,8 @@ class DreamyTranslationsPlugin implements Plugin.PluginBase {
/** /**
* Extract an object from a normal JSON RSC record. * Extract an object from a normal JSON RSC record.
*/ */
private extractRscObject<T>( private extractRscObject<T>(rscText: string, marker: string): T {
rscText: string, const line = rscText.split('\n').find(l => l.includes(marker));
marker: string,
): T {
const line = rscText
.split('\n')
.find(l => l.includes(marker));
if (!line) { if (!line) {
throw new Error( throw new Error(
@@ -101,9 +104,7 @@ class DreamyTranslationsPlugin implements Plugin.PluginBase {
const colonIndex = line.indexOf(':'); const colonIndex = line.indexOf(':');
if (colonIndex === -1) { if (colonIndex === -1) {
throw new Error( throw new Error('Invalid Dreamy Translations RSC record');
'Invalid Dreamy Translations RSC record',
);
} }
const jsonStr = line.slice(colonIndex + 1); const jsonStr = line.slice(colonIndex + 1);
@@ -113,10 +114,7 @@ class DreamyTranslationsPlugin implements Plugin.PluginBase {
* Next.js Flight records commonly contain the actual * Next.js Flight records commonly contain the actual
* application data at index 3. * application data at index 3.
*/ */
if ( if (Array.isArray(parsed) && parsed.length > 3) {
Array.isArray(parsed) &&
parsed.length > 3
) {
return parsed[3] as T; return parsed[3] as T;
} }
@@ -134,42 +132,26 @@ class DreamyTranslationsPlugin implements Plugin.PluginBase {
* response must be sliced by encoded bytes rather than * response must be sliced by encoded bytes rather than
* JavaScript string characters. * JavaScript string characters.
*/ */
private extractDeferredText( private extractDeferredText(rscText: string, refId: string): string {
rscText: string, const escapedId = refId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
refId: string,
): string {
const escapedId = refId.replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&',
);
const match = new RegExp( const match = new RegExp(`(?:^|\\n)${escapedId}:T([0-9a-fA-F]+),`).exec(
`(?:^|\\n)${escapedId}:T([0-9a-fA-F]+),`, rscText,
).exec(rscText); );
if (!match) { if (!match) {
throw new Error( throw new Error('Could not locate chapter content in server response');
'Could not locate chapter content in server response',
);
} }
const start = const start = match.index + match[0].length;
match.index + match[0].length;
const byteLength = parseInt( const byteLength = parseInt(match[1], 16);
match[1],
16,
);
const rest = rscText.slice(start); const rest = rscText.slice(start);
const bytes = new TextEncoder().encode( const bytes = new TextEncoder().encode(rest);
rest,
);
return new TextDecoder().decode( return new TextDecoder().decode(bytes.slice(0, byteLength));
bytes.slice(0, byteLength),
);
} }
/** /**
@@ -178,38 +160,24 @@ class DreamyTranslationsPlugin implements Plugin.PluginBase {
* This is a non-throwing version of extractDeferredText() * This is a non-throwing version of extractDeferredText()
* used when chapter content may already be inline. * used when chapter content may already be inline.
*/ */
private findDeferredText( private findDeferredText(rscText: string, refId: string): string | undefined {
rscText: string, const escapedId = refId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
refId: string,
): string | undefined {
const escapedId = refId.replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&',
);
const match = new RegExp( const match = new RegExp(`(?:^|\\n)${escapedId}:T([0-9a-fA-F]+),`).exec(
`(?:^|\\n)${escapedId}:T([0-9a-fA-F]+),`, rscText,
).exec(rscText); );
if (!match) { if (!match) {
return undefined; return undefined;
} }
const start = const start = match.index + match[0].length;
match.index + match[0].length;
const byteLength = parseInt( const byteLength = parseInt(match[1], 16);
match[1],
16,
);
const bytes = new TextEncoder().encode( const bytes = new TextEncoder().encode(rscText.slice(start));
rscText.slice(start),
);
return new TextDecoder().decode( return new TextDecoder().decode(bytes.slice(0, byteLength));
bytes.slice(0, byteLength),
);
} }
/** /**
@@ -223,47 +191,30 @@ class DreamyTranslationsPlugin implements Plugin.PluginBase {
* 3. Parse a balanced JSON object. * 3. Parse a balanced JSON object.
* 4. Fall back to other JSON-looking Flight records. * 4. Fall back to other JSON-looking Flight records.
*/ */
private extractChapterObject( private extractChapterObject(rscText: string): ChapterDetailData {
rscText: string,
): ChapterDetailData {
const lines = rscText.split('\n'); const lines = rscText.split('\n');
/* /*
* First attempt: normal Flight record. * First attempt: normal Flight record.
*/ */
const chapterLine = lines.find( const chapterLine = lines.find(
line => line => line.includes('"chapter"') && line.includes('"hasAccess"'),
line.includes('"chapter"') &&
line.includes('"hasAccess"'),
); );
if (chapterLine) { if (chapterLine) {
const colonIndex = const colonIndex = chapterLine.indexOf(':');
chapterLine.indexOf(':');
if (colonIndex !== -1) { if (colonIndex !== -1) {
const payload = const payload = chapterLine.slice(colonIndex + 1);
chapterLine.slice(
colonIndex + 1,
);
try { try {
const parsed = const parsed = JSON.parse(payload);
JSON.parse(payload);
if ( if (Array.isArray(parsed) && parsed.length > 3 && parsed[3]) {
Array.isArray(parsed) &&
parsed.length > 3 &&
parsed[3]
) {
return parsed[3] as ChapterDetailData; return parsed[3] as ChapterDetailData;
} }
if ( if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
parsed &&
typeof parsed === 'object' &&
!Array.isArray(parsed)
) {
return parsed as ChapterDetailData; return parsed as ChapterDetailData;
} }
} catch { } catch {
@@ -280,45 +231,29 @@ class DreamyTranslationsPlugin implements Plugin.PluginBase {
* directly inside the Flight response. * directly inside the Flight response.
*/ */
const marker = '"chapter":{'; const marker = '"chapter":{';
const markerIndex = const markerIndex = rscText.indexOf(marker);
rscText.indexOf(marker);
if (markerIndex === -1) { if (markerIndex === -1) {
throw new Error( throw new Error('Could not locate chapter data in server response');
'Could not locate chapter data in server response',
);
} }
let start = -1; let start = -1;
for ( for (let i = markerIndex; i >= 0; i--) {
let i = markerIndex;
i >= 0;
i--
) {
if (rscText[i] !== '{') { if (rscText[i] !== '{') {
continue; continue;
} }
const candidate = const candidate = rscText.slice(i, markerIndex + marker.length);
rscText.slice(
i,
markerIndex + marker.length,
);
if ( if (candidate.startsWith('{') && candidate.includes('"chapter"')) {
candidate.startsWith('{') &&
candidate.includes('"chapter"')
) {
start = i; start = i;
break; break;
} }
} }
if (start === -1) { if (start === -1) {
throw new Error( throw new Error('Could not locate chapter object in server response');
'Could not locate chapter object in server response',
);
} }
/* /*
@@ -329,11 +264,7 @@ class DreamyTranslationsPlugin implements Plugin.PluginBase {
let inString = false; let inString = false;
let escaped = false; let escaped = false;
for ( for (let i = start; i < rscText.length; i++) {
let i = start;
i < rscText.length;
i++
) {
const char = rscText[i]; const char = rscText[i];
if (inString) { if (inString) {
@@ -359,16 +290,10 @@ class DreamyTranslationsPlugin implements Plugin.PluginBase {
depth--; depth--;
if (depth === 0) { if (depth === 0) {
const json = const json = rscText.slice(start, i + 1);
rscText.slice(
start,
i + 1,
);
try { try {
return JSON.parse( return JSON.parse(json) as ChapterDetailData;
json,
) as ChapterDetailData;
} catch { } catch {
break; break;
} }
@@ -381,26 +306,20 @@ class DreamyTranslationsPlugin implements Plugin.PluginBase {
* Flight records. * Flight records.
*/ */
for (const line of lines) { for (const line of lines) {
const colon = const colon = line.indexOf(':');
line.indexOf(':');
if (colon === -1) { if (colon === -1) {
continue; continue;
} }
const payload = const payload = line.slice(colon + 1);
line.slice(colon + 1);
if ( if (!payload.startsWith('[') && !payload.startsWith('{')) {
!payload.startsWith('[') &&
!payload.startsWith('{')
) {
continue; continue;
} }
try { try {
const parsed = const parsed = JSON.parse(payload);
JSON.parse(payload);
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
for (const value of parsed) { for (const value of parsed) {
@@ -436,35 +355,19 @@ class DreamyTranslationsPlugin implements Plugin.PluginBase {
/** /**
* Fetch every novel from the series page. * Fetch every novel from the series page.
*/ */
private async fetchAllNovels(): Promise< private async fetchAllNovels(): Promise<Plugin.NovelItem[]> {
Plugin.NovelItem[] const rscText = await this.fetchRsc(`${this.site}/series`);
> {
const rscText =
await this.fetchRsc(
`${this.site}/series`,
);
const data = const data = this.extractRscObject<SeriesListData>(rscText, '"projects"');
this.extractRscObject<SeriesListData>(
rscText,
'"projects"',
);
return data.projects.map( return data.projects.map(project => ({
project => ({ name: project.title,
name: project.title, path: `/novel/${project.slug}`,
path: `/novel/${project.slug}`, cover: data.squareImageUrls[String(project.id)] || defaultCover,
cover: }));
data.squareImageUrls[
String(project.id)
] || defaultCover,
}),
);
} }
async popularNovels( async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
pageNo: number,
): Promise<Plugin.NovelItem[]> {
if (pageNo !== 1) { if (pageNo !== 1) {
return []; return [];
} }
@@ -472,70 +375,46 @@ class DreamyTranslationsPlugin implements Plugin.PluginBase {
return this.fetchAllNovels(); return this.fetchAllNovels();
} }
async parseNovel( async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
novelPath: string, const rscText = await this.fetchRsc(`${this.site}${novelPath}`);
): Promise<Plugin.SourceNovel> {
const rscText =
await this.fetchRsc(
`${this.site}${novelPath}`,
);
const data = const data = this.extractRscObject<NovelDetailData>(
this.extractRscObject<NovelDetailData>( rscText,
rscText, '"chapters":[',
'"chapters":[', );
);
const novel: Plugin.SourceNovel = { const novel: Plugin.SourceNovel = {
path: novelPath, path: novelPath,
name: name: data.project.title || 'Untitled',
data.project.title || cover: data.coverUrl || defaultCover,
'Untitled', author: data.project.author,
cover: genres: (data.project.genres || []).join(', '),
data.coverUrl || summary: data.project.synopsis || data.project.short_synopsis,
defaultCover, status: data.project.completed
author: ? NovelStatus.Completed
data.project.author, : NovelStatus.Ongoing,
genres:
(data.project.genres || [])
.join(', '),
summary:
data.project.synopsis ||
data.project.short_synopsis,
status:
data.project.completed
? NovelStatus.Completed
: NovelStatus.Ongoing,
}; };
novel.chapters = // Filter locked chapters if the user enabled the setting
data.chapters.map( const shouldHideLocked = this.pluginSettings.hideLocked.value;
chapter => ({
name: chapter.free const chaptersToShow = shouldHideLocked
? chapter.title ? data.chapters.filter(ch => ch.free)
: `🔒 ${chapter.title}`, : data.chapters;
path:
`${novelPath}/chapter/${chapter.index}`, novel.chapters = chaptersToShow.map(chapter => ({
chapterNumber: name: chapter.free ? chapter.title : `🔒 ${chapter.title}`,
chapter.index, path: `${novelPath}/chapter/${chapter.index}`,
}), chapterNumber: chapter.index,
); }));
return novel; return novel;
} }
async parseChapter( async parseChapter(chapterPath: string): Promise<string> {
chapterPath: string, const rscText = await this.fetchRsc(`${this.site}${chapterPath}`);
): Promise<string> {
const rscText =
await this.fetchRsc(
`${this.site}${chapterPath}`,
);
const data = const data = this.extractChapterObject(rscText);
this.extractChapterObject(
rscText,
);
if (!data.hasAccess) { if (!data.hasAccess) {
throw new Error( throw new Error(
@@ -543,41 +422,25 @@ class DreamyTranslationsPlugin implements Plugin.PluginBase {
); );
} }
let content = let content = data.chapter.content;
data.chapter.content;
/* /*
* Chapter content may be a reference to * Chapter content may be a reference to
* a streamed RSC text record. * a streamed RSC text record.
*/ */
const refMatch = const refMatch =
typeof content === 'string' typeof content === 'string' ? /^\$([0-9a-zA-Z]+)$/.exec(content) : null;
? /^\$([0-9a-zA-Z]+)$/.exec(
content,
)
: null;
if (refMatch) { if (refMatch) {
const streamed = const streamed = this.findDeferredText(rscText, refMatch[1]);
this.findDeferredText(
rscText,
refMatch[1],
);
if ( if (streamed !== undefined) {
streamed !== undefined
) {
content = streamed; content = streamed;
} }
} }
if ( if (!content || !content.trim()) {
!content || throw new Error('Dreamy Translations returned an empty chapter');
!content.trim()
) {
throw new Error(
'Dreamy Translations returned an empty chapter',
);
} }
/* /*
@@ -587,41 +450,30 @@ class DreamyTranslationsPlugin implements Plugin.PluginBase {
* We intentionally do NOT HTML-escape the content. * We intentionally do NOT HTML-escape the content.
* Dreamy supplies actual HTML, including <img> tags. * Dreamy supplies actual HTML, including <img> tags.
*/ */
const normalized = const normalized = content
content .replace(/\r\n/g, '\n')
.replace(/\r\n/g, '\n') .replace(/\r/g, '\n')
.replace(/\r/g, '\n') /*
/* * Dreamy currently returns image src values
* Dreamy currently returns image src values * in this form:
* in this form: *
* * src="[https://example.com/image](https://example.com/image)"
* src="[https://example.com/image](https://example.com/image)" *
* * Convert that into:
* Convert that into: *
* * src="https://example.com/image"
* src="https://example.com/image" */
*/ .replace(/(\bsrc\s*=\s*["'])\[([^\]]+)\]\(\2\)(["'])/gi, '$1$2$3');
.replace(
/(\bsrc\s*=\s*["'])\[([^\]]+)\]\(\2\)(["'])/gi,
'$1$2$3',
);
return normalized return normalized
.split(/\n{2,}/) .split(/\n{2,}/)
.map( .map(paragraph => paragraph.trim())
paragraph =>
paragraph.trim(),
)
.filter(Boolean) .filter(Boolean)
.map(paragraph => { .map(paragraph => {
/* /*
* Don't wrap standalone images in <p>. * Don't wrap standalone images in <p>.
*/ */
if ( if (/^<img\b[^>]*\/?>$/i.test(paragraph)) {
/^<img\b[^>]*\/?>$/i.test(
paragraph,
)
) {
return paragraph; return paragraph;
} }
@@ -630,10 +482,7 @@ class DreamyTranslationsPlugin implements Plugin.PluginBase {
* converting ordinary newlines to * converting ordinary newlines to
* reader line breaks. * reader line breaks.
*/ */
return `<p>${paragraph.replace( return `<p>${paragraph.replace(/\n/g, '<br>')}</p>`;
/\n/g,
'<br>',
)}</p>`;
}) })
.join(''); .join('');
} }
@@ -646,22 +495,14 @@ class DreamyTranslationsPlugin implements Plugin.PluginBase {
return []; return [];
} }
const novels = const novels = await this.fetchAllNovels();
await this.fetchAllNovels();
const term = const term = searchTerm.toLowerCase();
searchTerm.toLowerCase();
return novels.filter( return novels.filter(novel => novel.name.toLowerCase().includes(term));
novel =>
novel.name
.toLowerCase()
.includes(term),
);
} }
resolveUrl = (path: string) => resolveUrl = (path: string) => this.site + path;
this.site + path;
} }
export default new DreamyTranslationsPlugin(); export default new DreamyTranslationsPlugin();