From c2c1658d1f63065ae69ba79537a8fe2657af2858 Mon Sep 17 00:00:00 2001 From: Vaibhav <183315953+D3ICIDE@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:10:06 +0530 Subject: [PATCH] (fix) Migrated Web Novel Translation to new domain (#2548) --- plugins/english/wntl.ts | 268 ++++++++++++++++++ plugins/multisrc/madara/sources.json | 9 - .../en}/webnoveltraslation/icon.png | Bin 3 files changed, 268 insertions(+), 9 deletions(-) create mode 100644 plugins/english/wntl.ts rename public/static/{multisrc/madara => src/en}/webnoveltraslation/icon.png (100%) diff --git a/plugins/english/wntl.ts b/plugins/english/wntl.ts new file mode 100644 index 0000000..92a1da2 --- /dev/null +++ b/plugins/english/wntl.ts @@ -0,0 +1,268 @@ +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { NovelStatus } from '@libs/novelStatus'; +import { defaultCover } from '@libs/defaultCover'; + +const SITE = 'https://wntl.net'; +const API = `${SITE}/api`; + +const endpoints = { + novels: () => `${API}/novels`, + views: (ids: string[]) => `${API}/views?novelIds=${ids.join(',')}`, + chapters: (id: string) => `${API}/chapters/${id}`, + chapter: (id: string, file: string) => `${API}/chapter-content/${id}/${file}`, +}; + +type APINovel = { + id: string; + title: string; + author?: string; + cover?: string; + description?: string; + genre?: string[]; + views?: string; + rating?: number; + 'alternate-title'?: string[]; + status?: string[]; + chapterCount?: number; + schedule?: string[]; + latestChapter?: ChapterRef; + recentChapters?: ChapterRef[]; +}; + +type ChapterRef = { + number?: number; + publishedAt?: string; + status?: string; +}; + +type APIChapter = { + id: string; + number: number; + title: string; + date: string; + publishedAt: string; + file: string; + status: string; +}; + +const absolute = (url?: string) => { + if (!url) return defaultCover; + return url.startsWith('http') ? url : `${SITE}/${url.replace(/^\/+/, '')}`; +}; + +const escapeHtml = (s: string) => + s.replace(/&/g, '&').replace(//g, '>'); + +const inline = (s: string) => + escapeHtml(s) + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1'); + +function markdownToHtml(md: string): string { + return md + .replace(/\r\n/g, '\n') + .split('\n') + .map(l => l.trim()) + .filter(Boolean) + .map(line => { + const h = line.match(/^(#{1,6})\s+(.*)$/); + if (h) return `${inline(h[2])}`; + if (/^(-{3,}|\*{3,}|_{3,})$/.test(line)) return '
'; + return `

${inline(line)}

`; + }) + .join('\n'); +} + +function parseViews(json: any): Record { + const out: Record = {}; + const src = json?.views ?? json?.data ?? json; + const countOf = (e: any) => Number(e?.viewCount ?? e?.views ?? e?.count) || 0; + if (Array.isArray(src)) { + for (const e of src) { + const id = e?.novelId ?? e?.id ?? e?.novel_id; + if (id != null) out[String(id)] = countOf(e); + } + } else if (src && typeof src === 'object') { + for (const [k, v] of Object.entries(src)) { + out[k] = + v !== null && typeof v === 'object' ? countOf(v) : Number(v) || 0; + } + } + return out; +} + +function lastUpdated(n: APINovel): number { + const now = Date.now(); + let newest = 0; + for (const c of [n.latestChapter, ...(n.recentChapters || [])]) { + if (!c || (c.status && c.status !== 'published')) continue; + const t = Date.parse(c.publishedAt || ''); + if (!isNaN(t) && t <= now && t > newest) newest = t; + } + return newest; +} + +function mapStatus(status?: string[]): string { + const s = (status || []).join(' ').toLowerCase(); + if (s.includes('ongoing')) return NovelStatus.Ongoing; + if (s.includes('complete')) return NovelStatus.Completed; + if (s.includes('hiatus')) return NovelStatus.OnHiatus; + return NovelStatus.Unknown; +} + +class WNTLPlugin implements Plugin.PluginBase { + id = 'WebNovelTraslation'; + name = 'Web Novel Translation'; + icon = 'src/en/webnoveltraslation/icon.png'; + site = 'https://wntl.net/'; + version = '2.3.0'; + + private static readonly PAGE_SIZE = 20; + private cache: { time: number; novels: APINovel[] } | null = null; + + private async getJson(url: string): Promise { + const res = await fetchApi(url); + if (!res.ok) { + throw new Error(`Could not reach site (${res.status}). Try WebView.`); + } + return res.json(); + } + + // The whole catalogue comes back in one request, so cache it briefly. + private async getAll(): Promise { + if (this.cache && Date.now() - this.cache.time < 60_000) { + return this.cache.novels; + } + const json = await this.getJson<{ novels: APINovel[] }>(endpoints.novels()); + const novels = json.novels || []; + this.cache = { time: Date.now(), novels }; + return novels; + } + + private toItem(n: APINovel): Plugin.NovelItem { + return { name: n.title, path: n.id, cover: absolute(n.cover) }; + } + + private viewsCache: { time: number; views: Record } | null = + null; + + // we fetch views to later use it to sort for popularity. + private async getViews(ids: string[]): Promise> { + if (this.viewsCache && Date.now() - this.viewsCache.time < 5 * 60_000) { + return this.viewsCache.views; + } + const views: Record = {}; + try { + const batches: string[][] = []; + for (let i = 0; i < ids.length; i += 20) + batches.push(ids.slice(i, i + 20)); + const results = await Promise.all( + batches.map(b => + fetchApi(endpoints.views(b)).then(r => (r.ok ? r.json() : {})), + ), + ); + results.forEach(r => Object.assign(views, parseViews(r))); + } catch {} + if (Object.keys(views).length) { + this.viewsCache = { time: Date.now(), views }; + } + return views; + } + + async popularNovels( + pageNo: number, + { showLatestNovels }: Plugin.PopularNovelsOptions = {} as any, + ): Promise { + let all = (await this.getAll()).filter(n => n.id && n.title); + + if (showLatestNovels) { + all = [...all].sort((a, b) => lastUpdated(b) - lastUpdated(a)); + } else { + const views = await this.getViews(all.map(n => n.id)); + + const score = (n: APINovel) => views[n.id] ?? (Number(n.views) || 0); + // Array.sort is stable, so ties keep the API order. + all = [...all].sort((a, b) => score(b) - score(a)); + } + + const start = (pageNo - 1) * WNTLPlugin.PAGE_SIZE; + return all + .slice(start, start + WNTLPlugin.PAGE_SIZE) + .map(n => this.toItem(n)); + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + if (pageNo > 1) return []; + const q = searchTerm.toLowerCase(); + const all = await this.getAll(); + return all + .filter( + n => + n.id && + n.title && + (n.title.toLowerCase().includes(q) || + (n['alternate-title'] || []).some(t => + t.toLowerCase().includes(q), + )), + ) + .map(n => this.toItem(n)); + } + + async parseNovel(novelPath: string): Promise { + const [all, chapterRes] = await Promise.all([ + this.getAll(), + this.getJson<{ chapters: APIChapter[] }>(endpoints.chapters(novelPath)), + ]); + + const n = all.find(x => x.id === novelPath); + if (!n) { + throw new Error('Novel not found. It may have been removed.'); + } + + const alt = (n['alternate-title'] || []).filter(Boolean); + const summary = + (n.description || '').trim() + + (alt.length ? `\n\nAlternative titles: ${alt.join(', ')}` : ''); + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: n.title, + cover: absolute(n.cover), + author: n.author || '', + summary, + genres: (n.genre || []).join(', '), + status: mapStatus(n.status), + }; + + novel.chapters = (chapterRes.chapters || []) + .filter(c => c.status === 'published') + .map(c => ({ + name: c.title, + path: `${novelPath}/${c.file}`, + releaseTime: c.publishedAt, + chapterNumber: c.number, + })) + .sort((a, b) => a.chapterNumber! - b.chapterNumber!); + + return novel; + } + + async parseChapter(chapterPath: string): Promise { + const [id, file] = chapterPath.split('/'); + const res = await fetchApi(endpoints.chapter(id, file)); + if (!res.ok) throw new Error(`Chapter fetch failed (${res.status})`); + return markdownToHtml(await res.text()); + } + + resolveUrl(path: string, isNovel?: boolean): string { + const [id, file] = path.split('/'); + if (isNovel || !file) return `${SITE}/series/${id}`; + return `${SITE}/read/${id}/${file.replace(/\.md$/, '')}`; + } +} + +export default new WNTLPlugin(); diff --git a/plugins/multisrc/madara/sources.json b/plugins/multisrc/madara/sources.json index e4413a8..c5d39c9 100644 --- a/plugins/multisrc/madara/sources.json +++ b/plugins/multisrc/madara/sources.json @@ -551,15 +551,6 @@ "downSince": 1768289212933 } }, - { - "id": "WebNovelTraslation", - "sourceSite": "https://webnoveltranslations.com/", - "sourceName": "Web Novel Translation", - "options": { - "lang": "English", - "useNewChapterEndpoint": true - } - }, { "id": "AnimesHoy12", "sourceSite": "https://animeshoy12.com/", diff --git a/public/static/multisrc/madara/webnoveltraslation/icon.png b/public/static/src/en/webnoveltraslation/icon.png similarity index 100% rename from public/static/multisrc/madara/webnoveltraslation/icon.png rename to public/static/src/en/webnoveltraslation/icon.png