From f45634934266f0bf00377b63840b859448076666 Mon Sep 17 00:00:00 2001 From: RevDev909 Date: Fri, 11 Sep 2026 17:20:13 +0300 Subject: [PATCH 1/2] feat(english): add coteReader plugin --- plugins/english/coteReader.ts | 327 +++++++++++++++++++++++ public/static/src/en/cotereader/icon.png | Bin 0 -> 5331 bytes 2 files changed, 327 insertions(+) create mode 100644 plugins/english/coteReader.ts create mode 100644 public/static/src/en/cotereader/icon.png diff --git a/plugins/english/coteReader.ts b/plugins/english/coteReader.ts new file mode 100644 index 000000000..f724e7746 --- /dev/null +++ b/plugins/english/coteReader.ts @@ -0,0 +1,327 @@ +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { Filters, FilterTypes } from '@libs/filterInputs'; +import { defaultCover } from '@libs/defaultCover'; +import { NovelStatus } from '@libs/novelStatus'; + +interface CoteNovelMeta { + id: string; + slug?: string; + title: string; + author?: string; + year?: string; + volumes?: Array<{ + id: string; + position?: number; + title: string; + slug?: string; + cover?: string; + url?: string; + type?: string; + section?: string; + }>; + tags?: string[]; + genres?: string[]; + cover?: string; + description?: string; + popularity?: number; + source?: string; +} + +interface VolumeTocItem { + title: string; + chapterIndex: number; +} + +interface VolumeApiResponse { + bookId?: string; + seriesId?: string; + seriesTitle?: string; + volumeTitle?: string; + toc?: VolumeTocItem[]; + chapters?: Record; +} + +class CoteReader implements Plugin.PagePlugin { + id = 'cote-reader'; + name = 'COTE Reader'; + site = 'https://cote-reader.me'; + icon = 'src/en/cotereader/icon.png'; + version = '1.0.0'; + + private novelCache = new Map(); + private canonicalIds = new Set([ + 'cote', + 'lotm', + 'rezero', + 'bunny-girl', + 'mushoku-tensei', + 'orv', + 'world', + 'reverend-insanity', + 'apothecary-diaries', + 'tensura', + 'tbate', + 'eightysix', + 'monogatari', + ]); + + private formatCover(cover?: string): string { + if (!cover) return defaultCover; + if (cover.startsWith('http://') || cover.startsWith('https://')) return cover; + if (cover.startsWith('/')) return `${this.site}${cover}`; + return `${this.site}/${cover}`; + } + + private cleanId(novelPath: string): string { + return novelPath + .replace(/^\/?novel\//i, '') + .replace(/^\//, '') + .split('/')[0] + .trim(); + } + + async popularNovels( + pageNo: number, + { filters }: Plugin.PopularNovelsOptions, + ): Promise { + let url = `${this.site}/api/novels?page=${pageNo}&limit=20`; + if (filters?.tag?.value) { + url += `&tag=${encodeURIComponent(filters.tag.value)}`; + } + + const res = await fetchApi(url); + if (!res.ok) { + throw new Error(`Failed to load popular novels: HTTP ${res.status}`); + } + + const data = (await res.json()) as { items?: CoteNovelMeta[] }; + const items = data.items || []; + + return items.map((novel) => ({ + name: novel.title, + path: `/novel/${novel.id}`, + cover: this.formatCover(novel.cover), + })); + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + const url = `${this.site}/api/novels?q=${encodeURIComponent(searchTerm)}&page=${pageNo}&limit=20`; + const res = await fetchApi(url); + if (!res.ok) { + throw new Error(`Failed to search novels: HTTP ${res.status}`); + } + + const data = (await res.json()) as { items?: CoteNovelMeta[] }; + const items = data.items || []; + + return items.map((novel) => ({ + name: novel.title, + path: `/novel/${novel.id}`, + cover: this.formatCover(novel.cover), + })); + } + + async parseNovel( + novelPath: string, + ): Promise { + const id = this.cleanId(novelPath); + const metaUrl = `${this.site}/api/novels/${id}`; + const res = await fetchApi(metaUrl); + + if (!res.ok) { + throw new Error(`Failed to load novel metadata: HTTP ${res.status}`); + } + + const data = (await res.json()) as CoteNovelMeta; + this.novelCache.set(id, data); + + const volumes = data.volumes || []; + const totalPages = Math.max(1, volumes.length); + + let initialChapters: Plugin.ChapterItem[] = []; + if (totalPages > 0) { + const firstPage = await this.parsePage(novelPath, '1'); + initialChapters = firstPage.chapters; + } + + const genres = Array.isArray(data.tags) + ? data.tags.join(', ') + : Array.isArray(data.genres) + ? data.genres.join(', ') + : undefined; + + return { + path: `/novel/${data.id || id}`, + name: data.title, + cover: this.formatCover(data.cover), + summary: data.description, + author: data.author, + genres, + status: NovelStatus.Ongoing, + totalPages, + chapters: initialChapters, + }; + } + + async parsePage(novelPath: string, page: string): Promise { + const id = this.cleanId(novelPath); + let novelMeta = this.novelCache.get(id); + + if (!novelMeta) { + const res = await fetchApi(`${this.site}/api/novels/${id}`); + if (res.ok) { + novelMeta = (await res.json()) as CoteNovelMeta; + this.novelCache.set(id, novelMeta); + } + } + + const volumes = novelMeta?.volumes || []; + const pageNum = parseInt(page, 10); + const volIndex = isNaN(pageNum) || pageNum < 1 ? 0 : pageNum - 1; + const volume = volumes[volIndex]; + + if (!volume) { + return { chapters: [] }; + } + + const chapters: Plugin.ChapterItem[] = []; + const isCanonical = this.canonicalIds.has(id.toLowerCase()); + + if (isCanonical) { + const cacheUrl = `${this.site}/assets/cache/${id}/${volume.id}.json`; + const res = await fetchApi(cacheUrl); + + if (res.ok) { + const json = (await res.json()) as Record< + string, + { title?: string; content?: string } + >; + const keys = Object.keys(json).sort((a, b) => Number(a) - Number(b)); + + for (const key of keys) { + const item = json[key]; + chapters.push({ + name: `${volume.title} - ${item.title || `Chapter ${key}`}`, + path: `/canonical/${id}/${volume.id}/${key}`, + chapterNumber: Number(key), + }); + } + return { chapters }; + } + } + + // Non-canonical novel (or canonical fallback) + const volApiUrl = `${this.site}/api/novels/${id}/volume/${volume.id}`; + const res = await fetchApi(volApiUrl); + + if (res.ok) { + const volData = (await res.json()) as VolumeApiResponse; + const toc = volData.toc || []; + + for (const item of toc) { + chapters.push({ + name: `${volume.title} - ${item.title}`, + path: `/api/novels/${id}/volume/${volume.id}/${item.chapterIndex}`, + chapterNumber: item.chapterIndex, + }); + } + } + + return { chapters }; + } + + async parseChapter(chapterPath: string): Promise { + let rawHtml = ''; + + if (chapterPath.startsWith('/canonical/')) { + const parts = chapterPath.replace(/^\/canonical\//, '').split('/'); + const id = parts[0]; + const volumeId = parts[1]; + const chapterKey = parts[2]; + + const cacheUrl = `${this.site}/assets/cache/${id}/${volumeId}.json`; + const res = await fetchApi(cacheUrl); + if (!res.ok) { + throw new Error(`Failed to fetch chapter: HTTP ${res.status}`); + } + + const json = (await res.json()) as Record; + rawHtml = json[chapterKey]?.content || ''; + } else if (chapterPath.startsWith('/api/novels/')) { + const parts = chapterPath.replace(/^\/api\/novels\//, '').split('/'); + const id = parts[0]; + const volumeId = parts[2]; + const chapterIndex = parts[3]; + + const volUrl = `${this.site}/api/novels/${id}/volume/${volumeId}`; + const res = await fetchApi(volUrl); + if (!res.ok) { + throw new Error(`Failed to fetch chapter: HTTP ${res.status}`); + } + + const volData = (await res.json()) as VolumeApiResponse; + rawHtml = volData.chapters?.[chapterIndex]?.content || ''; + } else { + const res = await fetchApi( + chapterPath.startsWith('http') ? chapterPath : `${this.site}${chapterPath}`, + ); + if (res.ok) { + rawHtml = await res.text(); + } + } + + // Sanitize image paths and picture tags + return this.sanitizeChapterHtml(rawHtml); + } + + private sanitizeChapterHtml(html: string): string { + if (!html) return ''; + + // Replace relative images /assets/... with full domain URL + let cleaned = html.replace( + /(]+src=["'])(\/assets\/[^"']+)(["'])/gi, + `$1${this.site}$2$3`, + ); + + // Some chapters use + // Ensure standard has the best accessible URL (JPG/PNG/WebP) + cleaned = cleaned.replace( + /[\s\S]*?]+src=["']https?:\/\/[^"']+["'][^>]*)>[\s\S]*?<\/picture>/gi, + '', + ); + + return cleaned; + } + + filters = { + tag: { + value: '', + label: 'Genre / Tag', + options: [ + { label: 'All', value: '' }, + { label: 'Action', value: 'Action' }, + { label: 'Adventure', value: 'Adventure' }, + { label: 'Comedy', value: 'Comedy' }, + { label: 'Dark Fantasy', value: 'Dark Fantasy' }, + { label: 'Drama', value: 'Drama' }, + { label: 'Fantasy', value: 'Fantasy' }, + { label: 'Historical', value: 'Historical' }, + { label: 'Isekai', value: 'Isekai' }, + { label: 'Mystery', value: 'Mystery' }, + { label: 'Psychological', value: 'Psychological' }, + { label: 'Romance', value: 'Romance' }, + { label: 'School', value: 'School' }, + { label: 'Sci-Fi', value: 'Sci-Fi' }, + { label: 'Slice of Life', value: 'Slice of Life' }, + { label: 'Supernatural', value: 'Supernatural' }, + ], + type: FilterTypes.Picker, + }, + } satisfies Filters; +} + +export default new CoteReader(); diff --git a/public/static/src/en/cotereader/icon.png b/public/static/src/en/cotereader/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cec0165348416d1c59244616686b8d40e88d08c7 GIT binary patch literal 5331 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

MA_P4P# zT5fLX$#%TWV1Yf;R}Ete6>Ga#ELVHH-pdhxg>)O}V&Ewnetdbf2+gT3dN!%7%KkGGyJBZ3#a8*Vgu`0O;0emaLisHD lO!@5W>~Ox>v9p?O{|}sr1(%4tdL{q>002ovPDHLkV1j|WGw=Wa literal 0 HcmV?d00001 From a9d5738514c2542c5455353f9bc2e4d55df300b1 Mon Sep 17 00:00:00 2001 From: RevDev909 Date: Fri, 11 Sep 2026 17:26:02 +0300 Subject: [PATCH 2/2] feat(english): list all volumes ordered in single list --- plugins/english/coteReader.ts | 144 ++++++++++++---------------------- 1 file changed, 52 insertions(+), 92 deletions(-) diff --git a/plugins/english/coteReader.ts b/plugins/english/coteReader.ts index f724e7746..5e061b3a7 100644 --- a/plugins/english/coteReader.ts +++ b/plugins/english/coteReader.ts @@ -42,14 +42,13 @@ interface VolumeApiResponse { chapters?: Record; } -class CoteReader implements Plugin.PagePlugin { +class CoteReader implements Plugin.PluginBase { id = 'cote-reader'; name = 'COTE Reader'; site = 'https://cote-reader.me'; icon = 'src/en/cotereader/icon.png'; - version = '1.0.0'; + version = '1.0.1'; - private novelCache = new Map(); private canonicalIds = new Set([ 'cote', 'lotm', @@ -125,9 +124,7 @@ class CoteReader implements Plugin.PagePlugin { })); } - async parseNovel( - novelPath: string, - ): Promise { + async parseNovel(novelPath: string): Promise { const id = this.cleanId(novelPath); const metaUrl = `${this.site}/api/novels/${id}`; const res = await fetchApi(metaUrl); @@ -137,16 +134,22 @@ class CoteReader implements Plugin.PagePlugin { } const data = (await res.json()) as CoteNovelMeta; - this.novelCache.set(id, data); - const volumes = data.volumes || []; - const totalPages = Math.max(1, volumes.length); + const isCanonical = this.canonicalIds.has(id.toLowerCase()); - let initialChapters: Plugin.ChapterItem[] = []; - if (totalPages > 0) { - const firstPage = await this.parsePage(novelPath, '1'); - initialChapters = firstPage.chapters; - } + const chapters: Plugin.ChapterItem[] = volumes.map((volume, index) => { + const position = volume.position || index + 1; + const title = volume.title || `Volume ${position}`; + const path = isCanonical + ? `/canonical/${id}/${volume.id}` + : `/api/novels/${id}/volume/${volume.id}`; + + return { + name: title, + path, + chapterNumber: position, + }; + }); const genres = Array.isArray(data.tags) ? data.tags.join(', ') @@ -162,78 +165,10 @@ class CoteReader implements Plugin.PagePlugin { author: data.author, genres, status: NovelStatus.Ongoing, - totalPages, - chapters: initialChapters, + chapters, }; } - async parsePage(novelPath: string, page: string): Promise { - const id = this.cleanId(novelPath); - let novelMeta = this.novelCache.get(id); - - if (!novelMeta) { - const res = await fetchApi(`${this.site}/api/novels/${id}`); - if (res.ok) { - novelMeta = (await res.json()) as CoteNovelMeta; - this.novelCache.set(id, novelMeta); - } - } - - const volumes = novelMeta?.volumes || []; - const pageNum = parseInt(page, 10); - const volIndex = isNaN(pageNum) || pageNum < 1 ? 0 : pageNum - 1; - const volume = volumes[volIndex]; - - if (!volume) { - return { chapters: [] }; - } - - const chapters: Plugin.ChapterItem[] = []; - const isCanonical = this.canonicalIds.has(id.toLowerCase()); - - if (isCanonical) { - const cacheUrl = `${this.site}/assets/cache/${id}/${volume.id}.json`; - const res = await fetchApi(cacheUrl); - - if (res.ok) { - const json = (await res.json()) as Record< - string, - { title?: string; content?: string } - >; - const keys = Object.keys(json).sort((a, b) => Number(a) - Number(b)); - - for (const key of keys) { - const item = json[key]; - chapters.push({ - name: `${volume.title} - ${item.title || `Chapter ${key}`}`, - path: `/canonical/${id}/${volume.id}/${key}`, - chapterNumber: Number(key), - }); - } - return { chapters }; - } - } - - // Non-canonical novel (or canonical fallback) - const volApiUrl = `${this.site}/api/novels/${id}/volume/${volume.id}`; - const res = await fetchApi(volApiUrl); - - if (res.ok) { - const volData = (await res.json()) as VolumeApiResponse; - const toc = volData.toc || []; - - for (const item of toc) { - chapters.push({ - name: `${volume.title} - ${item.title}`, - path: `/api/novels/${id}/volume/${volume.id}/${item.chapterIndex}`, - chapterNumber: item.chapterIndex, - }); - } - } - - return { chapters }; - } - async parseChapter(chapterPath: string): Promise { let rawHtml = ''; @@ -246,11 +181,22 @@ class CoteReader implements Plugin.PagePlugin { const cacheUrl = `${this.site}/assets/cache/${id}/${volumeId}.json`; const res = await fetchApi(cacheUrl); if (!res.ok) { - throw new Error(`Failed to fetch chapter: HTTP ${res.status}`); + throw new Error(`Failed to fetch volume: HTTP ${res.status}`); } - const json = (await res.json()) as Record; - rawHtml = json[chapterKey]?.content || ''; + const json = (await res.json()) as Record; + if (chapterKey && json[chapterKey]) { + rawHtml = json[chapterKey].content || ''; + } else { + const keys = Object.keys(json).sort((a, b) => Number(a) - Number(b)); + rawHtml = keys + .map((k) => { + const item = json[k]; + const title = item.title || `Chapter ${k}`; + return `

${title}

${item.content || ''}
`; + }) + .join('\n
\n'); + } } else if (chapterPath.startsWith('/api/novels/')) { const parts = chapterPath.replace(/^\/api\/novels\//, '').split('/'); const id = parts[0]; @@ -260,11 +206,29 @@ class CoteReader implements Plugin.PagePlugin { const volUrl = `${this.site}/api/novels/${id}/volume/${volumeId}`; const res = await fetchApi(volUrl); if (!res.ok) { - throw new Error(`Failed to fetch chapter: HTTP ${res.status}`); + throw new Error(`Failed to fetch volume: HTTP ${res.status}`); } const volData = (await res.json()) as VolumeApiResponse; - rawHtml = volData.chapters?.[chapterIndex]?.content || ''; + if (chapterIndex && volData.chapters?.[chapterIndex]) { + rawHtml = volData.chapters[chapterIndex].content || ''; + } else { + const chaptersObj = volData.chapters || {}; + const toc = volData.toc || []; + if (toc.length > 0) { + rawHtml = toc + .map((item) => { + const ch = chaptersObj[item.chapterIndex]; + return `

${item.title}

${ch?.content || ''}
`; + }) + .join('\n
\n'); + } else { + const keys = Object.keys(chaptersObj).sort((a, b) => Number(a) - Number(b)); + rawHtml = keys + .map((k) => `
${chaptersObj[k]?.content || ''}
`) + .join('\n
\n'); + } + } } else { const res = await fetchApi( chapterPath.startsWith('http') ? chapterPath : `${this.site}${chapterPath}`, @@ -274,21 +238,17 @@ class CoteReader implements Plugin.PagePlugin { } } - // Sanitize image paths and picture tags return this.sanitizeChapterHtml(rawHtml); } private sanitizeChapterHtml(html: string): string { if (!html) return ''; - // Replace relative images /assets/... with full domain URL let cleaned = html.replace( /(]+src=["'])(\/assets\/[^"']+)(["'])/gi, `$1${this.site}$2$3`, ); - // Some chapters use - // Ensure standard has the best accessible URL (JPG/PNG/WebP) cleaned = cleaned.replace( /[\s\S]*?]+src=["']https?:\/\/[^"']+["'][^>]*)>[\s\S]*?<\/picture>/gi, '',