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