feat: Add iNovelTranslation (#2096)

* feat(english): add iNovelTranslation plugin

* feat(english): add iNovelTranslation plugin

* Delete plugins/index.ts

* Initialize plugins index with multiple imports
This commit is contained in:
7ui77
2026-04-10 09:57:25 +07:00
committed by GitHub
parent 3e0c7fd849
commit 95a04250fa
2 changed files with 359 additions and 0 deletions
+359
View File
@@ -0,0 +1,359 @@
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { Filters } from '@libs/filterInputs';
import { load as loadCheerio } from 'cheerio';
import { defaultCover } from '@libs/defaultCover';
import { NovelStatus } from '@libs/novelStatus';
import { storage } from '@libs/storage';
class INovelTranslation implements Plugin.PluginBase {
id = 'inoveltranslation';
name = 'iNovelTranslation';
icon = 'src/en/inoveltranslation/icon.png';
site = 'https://inoveltranslation.com';
version = '1.0.0';
filters: Filters | undefined = undefined;
pluginSettings = {
hideLocked: {
value: false,
label: 'Hide locked chapters',
type: 'Switch',
},
};
// Optimized stealth headers to mirror a real browser environment
private readonly HEADERS = {
'Accept':
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'Referer': 'https://inoveltranslation.com/',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-origin',
};
async popularNovels(
pageNo: number,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
options: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
const url = `${this.site}/api/novels?limit=50&page=${pageNo}`;
const result = await fetchApi(url, { headers: this.HEADERS }).then(r =>
r.json(),
);
const novels: Plugin.NovelItem[] = [];
if (result.docs) {
result.docs.forEach((doc: any) => {
novels.push({
name: doc.title,
path: `/novels/${doc.id}`,
cover: doc.cover?.url ? this.site + doc.cover.url : defaultCover,
});
});
}
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const id = novelPath.split('/').pop();
const novelUrl = `${this.site}/api/novels/${id}?depth=1`;
const novelData = await fetchApi(novelUrl, { headers: this.HEADERS }).then(
r => r.json(),
);
const chaptersUrl = `${this.site}/api/chapters?where[novel][equals]=${id}&limit=999&depth=0`;
const chaptersData = await fetchApi(chaptersUrl, {
headers: this.HEADERS,
}).then(r => r.json());
// Extract status
const status =
novelData.publication === 'completed'
? NovelStatus.Completed
: NovelStatus.Ongoing;
// Extract genres (tags)
const genres = novelData.tags
? novelData.tags.map((tag: any) => tag.name).join(', ')
: '';
// Process summary (Lexical JSON from API 'sypnosis') - use Plain Text for App Synopsis
let summary = '';
if (novelData.sypnosis && novelData.sypnosis.root) {
summary = this.lexicalToText(novelData.sypnosis.root);
}
const novel: Plugin.SourceNovel = {
path: novelPath,
name: novelData.title || 'Untitled',
cover: novelData.cover?.url
? this.site + novelData.cover.url
: defaultCover,
summary: summary,
author: novelData.author?.name || 'Unknown',
genres: genres,
status: status,
};
const chapters: Plugin.ChapterItem[] = [];
const hideLocked = storage.get('hideLocked');
if (chaptersData.docs) {
chaptersData.docs.forEach((doc: any) => {
const isLocked = doc.tier !== null;
if (isLocked && hideLocked) {
return;
}
const title = doc.title ? ` - ${doc.title}` : '';
const lockIcon = isLocked ? ' 🔒' : '';
chapters.push({
name: `Ch. ${doc.chapter}${lockIcon}${title}`,
path: `/chapters/${doc.id}`,
releaseTime: doc.updatedAt,
chapterNumber: doc.chapter,
});
});
}
// Ensure chapters are sorted numerically
novel.chapters = chapters.sort(
(a, b) => (a.chapterNumber || 0) - (b.chapterNumber || 0),
);
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
// Artificial delay to prevent aggressive rate limiting
await new Promise(res => setTimeout(res, 1500));
const rscHeader = { ...this.HEADERS, rsc: '1' };
let response;
try {
response = await fetchApi(this.site + chapterPath, {
headers: rscHeader,
});
} catch (e: any) {
throw new Error(`Network error: ${e.message}`);
}
if (response.status !== 200) {
throw new Error(
`Cloudflare challenge or server error (Status: ${response.status}). Please open in WebView to verify.`,
);
}
const rscText = await response.text();
if (!rscText || rscText.trim() === '') {
throw new Error('Server returned empty data.');
}
// 1. Proactive Cloudflare Detection
if (
rscText.includes('cf-browser-verification') ||
rscText.includes('cf-challenge') ||
rscText.includes('cloudflare-static') ||
rscText.includes('Just a moment...')
) {
if (!rscText.includes('root') && !rscText.includes('paragraph')) {
throw new Error(
'Cloudflare Challenge detected. Please open this novel in WebView to solve the challenge.',
);
}
}
// ==========================================
// 2. DEEP LEXICAL EXTRACTION ALGORITHM
// ==========================================
// 2.1. Basic cleanup of escaped characters in the RSC stream
let cleanText = rscText.replace(/\\"/g, '"').replace(/\\\\/g, '\\');
// 2.2. Locate the core content signature (first paragraph children)
const signature = '"children":[{"type":"paragraph"';
let sigIndex = cleanText.indexOf(signature);
if (sigIndex !== -1) {
// 2.3. Backtrack to find the opening brace { of the Lexical Object
let startIndex = cleanText.lastIndexOf('{', sigIndex);
// Check if it is within a "root": { ... } object to backtrack one level further
const rootIndex = cleanText.lastIndexOf('"root"', sigIndex);
if (rootIndex !== -1 && rootIndex > startIndex - 30) {
startIndex = cleanText.lastIndexOf('{', rootIndex);
}
if (startIndex !== -1) {
// 2.4. High-performance Brace Balancing algorithm
let braces = 0;
let inString = false;
let escape = false;
let jsonStr = '';
for (let i = startIndex; i < cleanText.length; i++) {
const char = cleanText[i];
if (escape) {
escape = false;
continue;
}
if (char === '\\') {
escape = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (!inString) {
if (char === '{') braces++;
else if (char === '}') braces--;
}
if (braces === 0 && i > startIndex) {
jsonStr = cleanText.substring(startIndex, i + 1);
break;
}
}
if (jsonStr) {
try {
// 2.5 Standardize and Parse JSON
// Strip control characters that might break JSON.parse
const safeJson = jsonStr.replace(/[\x00-\x1F\x7F-\x9F]/g, '');
const parsedData = JSON.parse(safeJson);
let lexicalRoot = parsedData.root || parsedData;
if (lexicalRoot && lexicalRoot.children) {
return this.lexicalToHtml(lexicalRoot);
}
} catch (e: any) {
// ==========================================
// 3. ULTIMATE FAILSAFE (REGEX TEXT EXTRACTION)
// ==========================================
// If JSON parsing fails due to corrupted RSC stream segments,
// we extract all "text":"..." fragments to reconstruct the story.
let fallbackHtml = '';
const textMatches = jsonStr.match(/"text":"(.*?)"/g);
if (textMatches && textMatches.length > 0) {
textMatches.forEach(m => {
let text = m.substring(8, m.length - 1);
if (text.trim() && text !== ' ') {
fallbackHtml += `<p>${text}</p>`;
}
});
return fallbackHtml;
}
throw new Error(
`JSON Parse error: ${e.message}. Data snippet: ${jsonStr.substring(0, 500)}`,
);
}
}
}
}
// ==========================================
// 4. Final HTML Scavenger Fallback
// ==========================================
const $ = loadCheerio(rscText);
let htmlContent = $(
'main > section[data-sentry-component="RichText"]',
).html();
if (htmlContent) return htmlContent;
throw new Error(
'Story content not found. Cloudflare might be blocking the request or the page structure has changed. Please try opening in WebView first.',
);
}
/**
* Recursively converts Lexical JSON nodes to HTML strings.
* Suitable for Chapter Content.
*/
private lexicalToHtml(node: any): string {
let html = '';
if (node.children) {
for (const child of node.children) {
if (child.type === 'paragraph') {
html += `<p>${this.lexicalToHtml(child)}</p>`;
} else if (child.type === 'text') {
let text = child.text || '';
if (child.format & 1) text = `<b>${text}</b>`;
if (child.format & 2) text = `<i>${text}</i>`;
html += text;
} else if (child.type === 'list') {
const tag = child.listType === 'number' ? 'ol' : 'ul';
html += `<${tag}>${this.lexicalToHtml(child)}</${tag}>`;
} else if (child.type === 'listitem') {
html += `<li>${this.lexicalToHtml(child)}</li>`;
} else if (child.type === 'heading') {
const tag = child.tag || 'h3';
html += `<${tag}>${this.lexicalToHtml(child)}</${tag}>`;
} else {
html += this.lexicalToHtml(child);
}
}
}
return html;
}
/**
* Recursively converts Lexical JSON nodes to Plain Text with newlines.
* Suitable for Novel Synopsis in the app.
*/
private lexicalToText(node: any): string {
let textOut = '';
if (node.children) {
for (const child of node.children) {
if (child.type === 'paragraph') {
textOut += this.lexicalToText(child) + '\n\n';
} else if (child.type === 'text') {
textOut += child.text || '';
} else if (child.type === 'listitem') {
textOut += '• ' + this.lexicalToText(child) + '\n';
} else {
textOut += this.lexicalToText(child);
}
}
}
return textOut;
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
const url = `${this.site}/api/novels?where[title][contains]=${encodeURIComponent(
searchTerm,
)}&limit=50&page=${pageNo}`;
const result = await fetchApi(url, { headers: this.HEADERS }).then(r =>
r.json(),
);
const novels: Plugin.NovelItem[] = [];
if (result.docs) {
result.docs.forEach((doc: any) => {
novels.push({
name: doc.title,
path: `/novels/${doc.id}`,
cover: doc.cover?.url ? this.site + doc.cover.url : defaultCover,
});
});
}
return novels;
}
resolveUrl = (path: string, isNovel?: boolean) => this.site + path;
}
export default new INovelTranslation();
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB