diff --git a/plugins/vietnamese/truyendich.ts b/plugins/vietnamese/truyendich.ts new file mode 100644 index 0000000..dc8cfe3 --- /dev/null +++ b/plugins/vietnamese/truyendich.ts @@ -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 = { + 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(); diff --git a/public/static/src/vi/truyendich/icon.png b/public/static/src/vi/truyendich/icon.png new file mode 100644 index 0000000..be79885 Binary files /dev/null and b/public/static/src/vi/truyendich/icon.png differ