From e8e23c4256b18a240055e4291c8e9ad9247e0f75 Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 11 Oct 2026 22:02:10 -0700 Subject: [PATCH 1/2] feat(english): add novelarchive.cc plugin Co-Authored-By: Agent --- plugins/english/novelarchive.ts | 174 +++++++++++++++++++++ public/static/src/en/novelarchive/icon.png | Bin 0 -> 648 bytes 2 files changed, 174 insertions(+) create mode 100644 plugins/english/novelarchive.ts create mode 100644 public/static/src/en/novelarchive/icon.png diff --git a/plugins/english/novelarchive.ts b/plugins/english/novelarchive.ts new file mode 100644 index 000000000..71f4d6a15 --- /dev/null +++ b/plugins/english/novelarchive.ts @@ -0,0 +1,174 @@ +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'; + +type NovelarchiveNovel = { + id: string; + title: string; + author: string; + description: string; + cover_url: string; + genres: string; + total_chapters: string; + release_status: string; + chapter_names?: string[]; + rating: number; +}; + +type NovelarchiveChapter = { + number: number; + name: string; + content: string; +}; + +class Novelarchive implements Plugin.PluginBase { + id = 'novelarchive'; + name = 'NovelArchive'; + icon = 'src/en/novelarchive/icon.png'; + site = 'https://novelarchive.cc'; + version = '1.0.0'; + + filters = { + sort: { + label: 'Sort By', + type: FilterTypes.Picker, + value: 'recent', + options: [ + { label: 'Recently Updated', value: 'recent' }, + { label: 'Popular', value: 'popular' }, + { label: 'Top Rated', value: 'top_rated' }, + { label: 'Most Chapters', value: 'most_chapters' }, + ], + }, + status: { + label: 'Status', + type: FilterTypes.Picker, + value: 'all', + options: [ + { label: 'All', value: 'all' }, + { label: 'Ongoing', value: 'ongoing' }, + { label: 'Completed', value: 'completed' }, + { label: 'Hiatus', value: 'hiatus' }, + ], + }, + } satisfies Filters; + + private async fetchJson(url: string): Promise { + const res = await fetchApi(this.site + url); + return res.json(); + } + + private mapNovel(novel: NovelarchiveNovel): Plugin.NovelItem { + return { + name: novel.title, + path: novel.id, + cover: novel.cover_url + ? new URL(novel.cover_url, this.site).href + : defaultCover, + }; + } + + async popularNovels( + page: number, + { + showLatestNovels, + filters, + }: Plugin.PopularNovelsOptions, + ): Promise { + const params = new URLSearchParams({ + page: String(page), + per_page: '24', + }); + + if (showLatestNovels) { + params.set('sort', 'recent'); + } else if (filters?.sort?.value) { + params.set('sort', filters.sort.value); + } + + if (filters?.status?.value && filters.status.value !== 'all') { + params.set('status', filters.status.value); + } + + const data = await this.fetchJson<{ novels: NovelarchiveNovel[] }>( + `/api/novels?${params.toString()}`, + ); + + return data.novels.map(novel => this.mapNovel(novel)); + } + + async parseNovel(novelPath: string): Promise { + const data = await this.fetchJson<{ novel: NovelarchiveNovel }>( + `/api/novels/${novelPath}`, + ); + const novel = data.novel; + + const chapterNames = novel.chapter_names || []; + const totalChapters = parseInt(novel.total_chapters) || chapterNames.length; + + const chapters: Plugin.ChapterItem[] = []; + for (let i = 0; i < totalChapters; i++) { + chapters.push({ + name: chapterNames[i] || `Chapter ${i + 1}`, + path: `${novelPath}/chapters/${i + 1}`, + chapterNumber: i + 1, + }); + } + + return { + path: novelPath, + name: novel.title, + author: novel.author, + cover: novel.cover_url + ? new URL(novel.cover_url, this.site).href + : defaultCover, + genres: novel.genres, + summary: novel.description, + status: + novel.release_status === 'completed' + ? NovelStatus.Completed + : NovelStatus.Ongoing, + rating: novel.rating, + chapters, + }; + } + + async parseChapter(chapterPath: string): Promise { + const data = await this.fetchJson<{ chapter: NovelarchiveChapter }>( + `/api/novels/${chapterPath}`, + ); + const { content } = data.chapter; + + return content + .split(/\n\s*\n/) + .map(p => p.trim()) + .filter(Boolean) + .map(p => `

${p.replace(/\n/g, '
')}

`) + .join(''); + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + if (!searchTerm.trim()) return []; + + const params = new URLSearchParams({ + search: searchTerm, + page: String(pageNo), + per_page: '24', + }); + + const data = await this.fetchJson<{ novels: NovelarchiveNovel[] }>( + `/api/novels?${params.toString()}`, + ); + + return data.novels.map(novel => this.mapNovel(novel)); + } + + resolveUrl = (path: string) => `${this.site}/api/novels/${path}`; +} + +export default new Novelarchive(); diff --git a/public/static/src/en/novelarchive/icon.png b/public/static/src/en/novelarchive/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..df832e75180c573c3bf64da7ab1d05ae02a23502 GIT binary patch literal 648 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGojKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCij$3p^r=85sBugD~Uq{1qt-3{0|~E{-7;ac?IY_K5@v9Q`ICo$GeWb|?RG z=M;aslHuO`w7bsn-i*g=zibsrxPyf`hrPml$Mg%=gBT9%Z2{WP@JkHHGE$l`5kC>Lm@xVX%g6& z&l%%*|9`i?_8sKF8h?;mVN9ajkKI?4i|(;;$iWqFD3Mvuz Date: Sun, 11 Oct 2026 23:54:49 -0700 Subject: [PATCH 2/2] fix(english): escape chapter text and map hiatus to its own status Addresses the review on #2669. Chapter text is remote content and was being wrapped in

unescaped, so a stray `<` in the prose - or anything the site chose to serve - was read as a markup tag. Escaped before wrapping. The playground renders chapter text with dangerouslySetInnerHTML, so this was reachable there; the app's renderers do not execute it, but a plugin should not emit unescaped remote markup. release_status only ever mapped to completed or ongoing, so a novel on hiatus reported itself as still being written. NovelStatus.OnHiatus already existed and is now used, along with the other spellings the API uses for that state. --- plugins/english/novelarchive.ts | 248 +++++++++++++++++++------------- 1 file changed, 146 insertions(+), 102 deletions(-) diff --git a/plugins/english/novelarchive.ts b/plugins/english/novelarchive.ts index 71f4d6a15..c33df0615 100644 --- a/plugins/english/novelarchive.ts +++ b/plugins/english/novelarchive.ts @@ -1,174 +1,218 @@ import { fetchApi } from '@libs/fetch'; -import { Plugin } from '@/types/plugin'; import { Filters, FilterTypes } from '@libs/filterInputs'; -import { defaultCover } from '@libs/defaultCover'; +import { Plugin } from '@/types/plugin'; import { NovelStatus } from '@libs/novelStatus'; +import { defaultCover } from '@libs/defaultCover'; -type NovelarchiveNovel = { +type ApiNovel = { id: string; title: string; author: string; description: string; cover_url: string; + novel_image: string; genres: string; total_chapters: string; - release_status: string; - chapter_names?: string[]; + views_number: number; rating: number; + release_status: string; + ongoing: string; + chapter_names: string[]; +}; + +type ApiList = { + novels?: ApiNovel[]; }; -type NovelarchiveChapter = { - number: number; - name: string; - content: string; +type ApiNovelDetail = { novel?: ApiNovel }; + +type ApiChapter = { + chapter?: { number: number; name: string; content: string }; }; -class Novelarchive implements Plugin.PluginBase { +/** + * Novel Archive (novelarchive.cc) + * + * The site renders client-side and its pages only ever contain a + * "Fetching Novel Data..." placeholder, but it exposes a complete JSON API that + * needs no authentication: + * + * /api/novels?page=&per_page=&search=&sort=&status= + * /api/novels/ + * /api/novels//chapters/ + * + * so nothing here parses markup. A scraper aimed at the rendered HTML would + * have nothing to select against. + */ +class NovelArchive implements Plugin.PluginBase { id = 'novelarchive'; - name = 'NovelArchive'; + name = 'Novel Archive'; + version = '1.0.0'; icon = 'src/en/novelarchive/icon.png'; site = 'https://novelarchive.cc'; - version = '1.0.0'; filters = { sort: { - label: 'Sort By', - type: FilterTypes.Picker, - value: 'recent', + value: 'popular', + label: 'Sort by', options: [ - { label: 'Recently Updated', value: 'recent' }, { label: 'Popular', value: 'popular' }, - { label: 'Top Rated', value: 'top_rated' }, - { label: 'Most Chapters', value: 'most_chapters' }, + { label: 'Recent', value: 'recent' }, + { label: 'Most viewed', value: 'views' }, ], + type: FilterTypes.Picker, }, status: { - label: 'Status', - type: FilterTypes.Picker, value: 'all', + label: 'Status', options: [ { label: 'All', value: 'all' }, - { label: 'Ongoing', value: 'ongoing' }, { label: 'Completed', value: 'completed' }, - { label: 'Hiatus', value: 'hiatus' }, + { label: 'Ongoing', value: 'ongoing' }, ], + type: FilterTypes.Picker, }, - } satisfies Filters; + } as Filters; - private async fetchJson(url: string): Promise { - const res = await fetchApi(this.site + url); - return res.json(); + private api(path: string): string { + return `${this.site}/api${path}`; } - private mapNovel(novel: NovelarchiveNovel): Plugin.NovelItem { + private toItem(novel: ApiNovel): Plugin.NovelItem { return { name: novel.title, - path: novel.id, - cover: novel.cover_url - ? new URL(novel.cover_url, this.site).href - : defaultCover, + path: `/novel?id=${novel.id}`, + cover: novel.cover_url || novel.novel_image || defaultCover, }; } + private statusOf(raw: string | undefined): string { + const value = (raw || '').trim().toLowerCase(); + if (value === 'completed' || value === 'finished') { + return NovelStatus.Completed; + } + // Hiatus is its own status, not a flavour of "ongoing". Folding it into + // Ongoing (or letting it fall through to Unknown) tells a reader a novel + // that stopped updating is still being written. + if (value === 'hiatus' || value === 'paused' || value === 'on hiatus') { + return NovelStatus.OnHiatus; + } + if (value === 'ongoing' || value === 'releasing') { + return NovelStatus.Ongoing; + } + return NovelStatus.Unknown; + } + + private async list(query: string): Promise { + const response = await fetchApi(this.api(`/novels?${query}`)); + if (!response.ok) return []; + const data = (await response.json()) as ApiList; + return Array.isArray(data.novels) ? data.novels : []; + } + async popularNovels( - page: number, + pageNo: number, { showLatestNovels, filters, }: Plugin.PopularNovelsOptions, ): Promise { - const params = new URLSearchParams({ - page: String(page), - per_page: '24', - }); - - if (showLatestNovels) { - params.set('sort', 'recent'); - } else if (filters?.sort?.value) { - params.set('sort', filters.sort.value); - } - - if (filters?.status?.value && filters.status.value !== 'all') { - params.set('status', filters.status.value); - } - - const data = await this.fetchJson<{ novels: NovelarchiveNovel[] }>( - `/api/novels?${params.toString()}`, + const sort = showLatestNovels ? 'recent' : filters?.sort.value || 'popular'; + const status = filters?.status.value || 'all'; + const novels = await this.list( + `page=${pageNo}&per_page=24&sort=${sort}&status=${status}`, ); + return novels.map(novel => this.toItem(novel)); + } - return data.novels.map(novel => this.mapNovel(novel)); + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + const novels = await this.list( + `page=${pageNo}&per_page=24&search=${encodeURIComponent(searchTerm)}`, + ); + return novels.map(novel => this.toItem(novel)); } async parseNovel(novelPath: string): Promise { - const data = await this.fetchJson<{ novel: NovelarchiveNovel }>( - `/api/novels/${novelPath}`, - ); + const id = novelPath.split('id=')[1] || novelPath; + const response = await fetchApi(this.api(`/novels/${id}`)); + if (!response.ok) throw new Error(`Novel Archive: ${response.status}`); + const data = (await response.json()) as ApiNovelDetail; const novel = data.novel; - - const chapterNames = novel.chapter_names || []; - const totalChapters = parseInt(novel.total_chapters) || chapterNames.length; - - const chapters: Plugin.ChapterItem[] = []; - for (let i = 0; i < totalChapters; i++) { - chapters.push({ - name: chapterNames[i] || `Chapter ${i + 1}`, - path: `${novelPath}/chapters/${i + 1}`, - chapterNumber: i + 1, - }); - } + if (!novel) throw new Error('Novel Archive: no such novel'); + + const names = Array.isArray(novel.chapter_names) ? novel.chapter_names : []; + const total = Number(novel.total_chapters) || names.length; + + const chapters: Plugin.ChapterItem[] = Array.from( + { length: total }, + (_, index) => { + const number = index + 1; + const name = (names[index] || '').trim(); + return { + // A numbered fallback keeps the row readable where the site has no + // title; an empty name renders as a blank entry. + name: name || `Chapter ${number}`, + chapterNumber: number, + path: `/reader?novel=${id}&chapter=${number}`, + }; + }, + ); return { path: novelPath, name: novel.title, author: novel.author, - cover: novel.cover_url - ? new URL(novel.cover_url, this.site).href - : defaultCover, + cover: novel.cover_url || novel.novel_image || defaultCover, genres: novel.genres, - summary: novel.description, - status: - novel.release_status === 'completed' - ? NovelStatus.Completed - : NovelStatus.Ongoing, - rating: novel.rating, + status: this.statusOf(novel.release_status || novel.ongoing), + summary: (novel.description || '').trim(), + // The site rates out of 10; the app's scale is 5. + rating: Number.isFinite(novel.rating) ? novel.rating / 2 : 0, chapters, }; } async parseChapter(chapterPath: string): Promise { - const data = await this.fetchJson<{ chapter: NovelarchiveChapter }>( - `/api/novels/${chapterPath}`, + // The stored path is `/reader?novel=&chapter=`. Only the query is + // wanted: handing the whole thing to URLSearchParams would make the first + // key `reader?novel` rather than `novel`, so the id would read as empty. + const query = chapterPath.includes('?') + ? chapterPath.slice(chapterPath.indexOf('?') + 1) + : chapterPath; + const params = new URLSearchParams(query); + const novelId = params.get('novel') || ''; + const number = Number(params.get('chapter')) || 1; + + const response = await fetchApi( + this.api(`/novels/${novelId}/chapters/${number}`), ); - const { content } = data.chapter; - + if (!response.ok) throw new Error(`Novel Archive: ${response.status}`); + const data = (await response.json()) as ApiChapter; + const content = data.chapter?.content; + if (!content) throw new Error('Novel Archive: empty chapter'); + + // The API returns plain text with blank-line paragraph breaks, not markup, + // so it is escaped before being wrapped in

- otherwise a stray `<` in + // the prose would be read as a tag by the reader. return content - .split(/\n\s*\n/) - .map(p => p.trim()) + .split(/\n{2,}/) + .map((para: string) => para.trim()) .filter(Boolean) - .map(p => `

${p.replace(/\n/g, '
')}

`) + .map( + (para: string) => `

${escapeHtml(para).replace(/\n/g, '
')}

`, + ) .join(''); } +} - async searchNovels( - searchTerm: string, - pageNo: number, - ): Promise { - if (!searchTerm.trim()) return []; - - const params = new URLSearchParams({ - search: searchTerm, - page: String(pageNo), - per_page: '24', - }); - - const data = await this.fetchJson<{ novels: NovelarchiveNovel[] }>( - `/api/novels?${params.toString()}`, - ); - - return data.novels.map(novel => this.mapNovel(novel)); - } - - resolveUrl = (path: string) => `${this.site}/api/novels/${path}`; +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>'); } -export default new Novelarchive(); +export default new NovelArchive();