feat(vi/TruyenDich): add new source (#2539)
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
|
||||
type ApiNovel = {
|
||||
slug: string;
|
||||
title: string;
|
||||
image_url?: string | null;
|
||||
status?: string | null;
|
||||
author?: string | null;
|
||||
description?: string | null;
|
||||
has_ai?: boolean;
|
||||
categories?: { name: string }[];
|
||||
editions?: { edition_name: string }[];
|
||||
};
|
||||
|
||||
type ApiPage<T> = {
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
items: T[];
|
||||
};
|
||||
|
||||
type ApiChapter = {
|
||||
chapter_number: number;
|
||||
title?: string | null;
|
||||
update_time?: string | null;
|
||||
content?: string | null;
|
||||
/** Set (e.g. "verify_now") when the site wants a Turnstile check first. */
|
||||
trust_challenge?: string | null;
|
||||
};
|
||||
|
||||
const VERIFY_MESSAGE =
|
||||
'Cloudflare protection detected (HTTP error). Please try opening the plugin in WebView first to solve the challenge.';
|
||||
|
||||
/**
|
||||
* The site is a Next.js front-end over a JSON API (`/api/...`); everything
|
||||
* here talks to the API directly. A novel can have several editions:
|
||||
* `translate` (human), `ai` and `convert`. The site addresses them as
|
||||
* `/doc-truyen/<slug>` (translate when present, otherwise the default),
|
||||
* `/doc-truyen/ai/<slug>` and `/doc-truyen/cv/<slug>`; paths returned by this
|
||||
* plugin follow the same shape so they resolve on the site as-is.
|
||||
*/
|
||||
type Edition = '' | 'ai' | 'cv';
|
||||
|
||||
const EDITION_TYPE: Record<Edition, string | undefined> = {
|
||||
'': undefined,
|
||||
ai: 'ai',
|
||||
cv: 'convert',
|
||||
};
|
||||
|
||||
class TruyenDichPlugin implements Plugin.PluginBase {
|
||||
id = 'truyendich';
|
||||
name = 'TruyenDich';
|
||||
icon = 'src/vi/truyendich/icon.png';
|
||||
site = 'https://truyendich.space';
|
||||
version = '1.0.0';
|
||||
|
||||
private async getJson<T>(path: string): Promise<T> {
|
||||
const res = await fetchApi(this.site + path);
|
||||
if (res.status === 429) {
|
||||
throw new Error(
|
||||
'truyendich.space rate limited the request (HTTP 429), try again later.',
|
||||
);
|
||||
}
|
||||
const body = await res.text();
|
||||
let data: T & { detail?: unknown };
|
||||
try {
|
||||
data = JSON.parse(body);
|
||||
} catch {
|
||||
throw new Error(`${path} -> HTTP ${res.status}, not JSON`);
|
||||
}
|
||||
if (data?.detail === 'VERIFY_HUMAN') {
|
||||
throw new Error(VERIFY_MESSAGE);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`${path} -> HTTP ${res.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private novelPath(slug: string, edition: Edition): string {
|
||||
return 'doc-truyen/' + (edition ? edition + '/' : '') + slug;
|
||||
}
|
||||
|
||||
private parsePath(novelPath: string): { slug: string; edition: Edition } {
|
||||
const m = novelPath.match(/^\/?doc-truyen\/(?:(ai|cv)\/)?([^/]+)/);
|
||||
if (!m) {
|
||||
throw new Error('Unrecognised path: ' + novelPath);
|
||||
}
|
||||
return { slug: m[2], edition: (m[1] as Edition) || '' };
|
||||
}
|
||||
|
||||
private toNovelItem(item: ApiNovel, edition: Edition): Plugin.NovelItem {
|
||||
return {
|
||||
name: item.title,
|
||||
path: this.novelPath(item.slug, edition),
|
||||
cover: item.image_url ? this.site + item.image_url : defaultCover,
|
||||
};
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const list = showLatestNovels ? 'truyen-moi' : filters.list.value;
|
||||
const edition = filters.edition.value as Edition;
|
||||
const page = await this.getJson<ApiPage<ApiNovel>>(
|
||||
`/api/lists/${list}?page=${pageNo}&size=24`,
|
||||
);
|
||||
return page.items.map(item => this.toNovelItem(item, edition));
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const { slug, edition } = this.parsePath(novelPath);
|
||||
const data = await this.getJson<ApiNovel>(
|
||||
`/api/novels/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: data.title,
|
||||
cover: data.image_url ? this.site + data.image_url : defaultCover,
|
||||
author: data.author || undefined,
|
||||
genres: data.categories?.map(c => c.name).join(', ') || undefined,
|
||||
summary: data.description
|
||||
? data.description
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/p>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
: undefined,
|
||||
status:
|
||||
data.status === 'ongoing'
|
||||
? NovelStatus.Ongoing
|
||||
: data.status === 'completed'
|
||||
? NovelStatus.Completed
|
||||
: NovelStatus.Unknown,
|
||||
};
|
||||
|
||||
// Chapter pages are capped at 200 entries; long series run into the
|
||||
// thousands, so the remaining pages are fetched a few at a time.
|
||||
const PAGE_SIZE = 200;
|
||||
const CONCURRENT_PAGES = 4;
|
||||
const editionQuery = EDITION_TYPE[edition]
|
||||
? '&edition_type=' + EDITION_TYPE[edition]
|
||||
: '';
|
||||
const chaptersUrl = (page: number) =>
|
||||
`/api/novels/${encodeURIComponent(slug)}/chapters?page=${page}&size=${PAGE_SIZE}${editionQuery}`;
|
||||
|
||||
const first = await this.getJson<ApiPage<ApiChapter>>(chaptersUrl(1));
|
||||
const pages: ApiChapter[][] = [first.items];
|
||||
const pageCount = Math.ceil(first.total / PAGE_SIZE);
|
||||
for (let p = 2; p <= pageCount; p += CONCURRENT_PAGES) {
|
||||
const batch: Promise<ApiPage<ApiChapter>>[] = [];
|
||||
for (let q = p; q < p + CONCURRENT_PAGES && q <= pageCount; q++) {
|
||||
batch.push(this.getJson<ApiPage<ApiChapter>>(chaptersUrl(q)));
|
||||
}
|
||||
const results = await Promise.all(batch);
|
||||
pages.push(...results.map(r => r.items));
|
||||
}
|
||||
|
||||
novel.chapters = pages
|
||||
.flat()
|
||||
.sort((a, b) => a.chapter_number - b.chapter_number)
|
||||
.map(ch => ({
|
||||
name: ch.title
|
||||
? `Chapter ${ch.chapter_number}: ${ch.title}`
|
||||
: `Chapter ${ch.chapter_number}`,
|
||||
path: `${this.novelPath(slug, edition)}/chuong-${ch.chapter_number}`,
|
||||
chapterNumber: ch.chapter_number,
|
||||
releaseTime: ch.update_time || undefined,
|
||||
}));
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const { slug, edition } = this.parsePath(chapterPath);
|
||||
const num = chapterPath.match(/\/chuong-(\d+)\s*$/)?.[1];
|
||||
if (!num) {
|
||||
throw new Error('Unrecognised chapter path: ' + chapterPath);
|
||||
}
|
||||
const editionQuery = EDITION_TYPE[edition]
|
||||
? '?edition_type=' + EDITION_TYPE[edition]
|
||||
: '';
|
||||
const data = await this.getJson<ApiChapter>(
|
||||
`/api/novels/${encodeURIComponent(slug)}/chapters/${num}${editionQuery}`,
|
||||
);
|
||||
|
||||
// After a few chapters the site returns an empty body and asks for a
|
||||
// Turnstile check; the app's WebView shares its cookies with fetchApi.
|
||||
if (!data.content && data.trust_challenge) {
|
||||
throw new Error(VERIFY_MESSAGE);
|
||||
}
|
||||
|
||||
// AI editions wrap the text in a <content> tag and use <br>; translated
|
||||
// editions are plain text with newlines.
|
||||
let content = (data.content || '').replace(/<\/?content>/gi, '').trim();
|
||||
if (!/<(br|p)\b/i.test(content)) {
|
||||
content = content.replace(/\n/g, '<br>');
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const page = await this.getJson<ApiPage<ApiNovel>>(
|
||||
`/api/novels/search?q=${encodeURIComponent(searchTerm)}&page=${pageNo}&size=24`,
|
||||
);
|
||||
return page.items.map(item => this.toNovelItem(item, ''));
|
||||
}
|
||||
|
||||
resolveUrl = (path: string) => this.site + '/' + path.replace(/^\//, '');
|
||||
|
||||
filters = {
|
||||
list: {
|
||||
type: FilterTypes.Picker,
|
||||
label: 'List',
|
||||
value: 'truyen-hot',
|
||||
options: [
|
||||
{ label: 'Hot', value: 'truyen-hot' },
|
||||
{ label: 'New', value: 'truyen-moi' },
|
||||
{ label: 'Translated', value: 'truyen-dich' },
|
||||
{ label: 'AI translated', value: 'truyen-dich-ai' },
|
||||
{ label: 'Convert', value: 'truyen-convert' },
|
||||
{ label: 'Completed', value: 'truyen-full' },
|
||||
],
|
||||
},
|
||||
edition: {
|
||||
type: FilterTypes.Picker,
|
||||
label: 'Edition',
|
||||
value: '',
|
||||
options: [
|
||||
{ label: 'Default (translated if available)', value: '' },
|
||||
{ label: 'AI translated', value: 'ai' },
|
||||
{ label: 'Convert', value: 'cv' },
|
||||
],
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new TruyenDichPlugin();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
Reference in New Issue
Block a user