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 = { 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/` (translate when present, otherwise the default), * `/doc-truyen/ai/` and `/doc-truyen/cv/`; 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 = { '': 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(path: string): Promise { 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, ): Promise { const list = showLatestNovels ? 'truyen-moi' : filters.list.value; const edition = filters.edition.value as Edition; const page = await this.getJson>( `/api/lists/${list}?page=${pageNo}&size=24`, ); return page.items.map(item => this.toNovelItem(item, edition)); } async parseNovel(novelPath: string): Promise { const { slug, edition } = this.parsePath(novelPath); const data = await this.getJson( `/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(//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>(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>[] = []; for (let q = p; q < p + CONCURRENT_PAGES && q <= pageCount; q++) { batch.push(this.getJson>(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 { 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( `/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 tag and use
; 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, '
'); } return content; } async searchNovels( searchTerm: string, pageNo: number, ): Promise { const page = await this.getJson>( `/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();