From 2680752467bc452dd03c3c6842c53eade20e891d Mon Sep 17 00:00:00 2001 From: 7ui77 <99854073+7ui77@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:03:34 +0700 Subject: [PATCH] feat: Add novelarrow (#2239) * Add NovelArrow plugin * Update plugins/english/novelarrow.ts Co-authored-by: K1ngfish3r <26593485+K1ngfish3r@users.noreply.github.com> * Update plugins/english/novelarrow.ts Co-authored-by: K1ngfish3r <26593485+K1ngfish3r@users.noreply.github.com> * Update plugins/english/novelarrow.ts Co-authored-by: K1ngfish3r <26593485+K1ngfish3r@users.noreply.github.com> * Update plugins/english/novelarrow.ts Co-authored-by: K1ngfish3r <26593485+K1ngfish3r@users.noreply.github.com> * Update plugins/english/novelarrow.ts Co-authored-by: K1ngfish3r <26593485+K1ngfish3r@users.noreply.github.com> * Fix novelarrow plugin based on reviewer feedback * Remove all custom headers from novelarrow plugin --------- Co-authored-by: K1ngfish3r <26593485+K1ngfish3r@users.noreply.github.com> --- plugins/english/novelarrow.ts | 212 +++++++++++++++++++++++ public/static/src/en/novelarrow/icon.png | Bin 0 -> 766 bytes 2 files changed, 212 insertions(+) create mode 100644 plugins/english/novelarrow.ts create mode 100644 public/static/src/en/novelarrow/icon.png diff --git a/plugins/english/novelarrow.ts b/plugins/english/novelarrow.ts new file mode 100644 index 0000000..9ae1734 --- /dev/null +++ b/plugins/english/novelarrow.ts @@ -0,0 +1,212 @@ +import { load as parseHTML } from 'cheerio'; +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { NovelStatus } from '@libs/novelStatus'; + +class NovelArrow implements Plugin.PluginBase { + id = 'novelarrow'; + name = 'Novel Arrow'; + icon = 'src/en/novelarrow/icon.png'; + site = 'https://novelarrow.com/'; + version = '1.0.0'; + + async popularNovels(page: number) { + const url = `${this.site}novels/latest?page=${page}`; + const result = await fetchApi(url).then(res => res.text()); + const $ = parseHTML(result); + const novels: Plugin.NovelItem[] = []; + + $('article').each((i, el) => { + const title = $(el).find('h2').text().trim(); + const cover = $(el).find('img').attr('src'); + const href = $(el).find('a').attr('href'); + + if (title && href) { + novels.push({ + name: title, + cover, + path: href.substring(1), // Result: "novel/slug" + }); + } + }); + + return novels; + } + + async parseNovel(novelPath: string) { + // Ensure no double slashes in the URL + const url = this.site + novelPath.replace(/^\//, ''); + const result = await fetchApi(url).then(res => res.text()); + const $ = parseHTML(result); + + const novelId = novelPath.replace('novel/', '').replace(/^\//, ''); + + // Collect genres + let genres = + $('meta[name="og:novel:genre"]').attr('content') || + $('meta[property="og:novel:genre"]').attr('content'); + + if (!genres) { + const genreList: string[] = []; + $('meta[property="article:tag"]').each((i, el) => { + const tag = $(el).attr('content'); + if (tag) genreList.push(tag); + }); + genres = genreList.join(', '); + } + + // Attempt to get the full summary from the JSON stream if the meta tag is truncated + let fullSummary = + $('meta[name="description"]').attr('content') || + $('meta[property="og:description"]').attr('content'); + const summaryMatch = result.match(/\\?"description\\?":\\?"(.*?)\\?"/); + if (summaryMatch && summaryMatch[1].length > (fullSummary?.length || 0)) { + fullSummary = summaryMatch[1].replace(/\\n/g, '\n').replace(/\\"/g, '"'); + } + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: + $('meta[name="og:novel:novel_name"]').attr('content') || + $('meta[property="og:novel:novel_name"]').attr('content') || + $('meta[property="og:title"]').attr('content')?.split(' Novel')[0] || + $('h1').first().text().trim(), + cover: + $('meta[property="og:image"]').attr('content') || + $('meta[name="og:image"]').attr('content'), + author: + $('meta[name="og:novel:author"]').attr('content') || + $('meta[property="og:novel:author"]').attr('content') || + $('meta[name="author"]').attr('content') || + $('meta[property="article:author"]').attr('content'), + status: + ($('meta[name="og:novel:status"]').attr('content') || + $('meta[property="og:novel:status"]').attr('content')) === 'Ongoing' + ? NovelStatus.Ongoing + : NovelStatus.Completed, + summary: fullSummary, + genres: genres, + chapters: [], + }; + + const chaptersUrl = `${this.site}api-web/novels/${novelId}/chapters?sort=asc`; + try { + const chaptersJson = await fetchApi(chaptersUrl, { + headers: { + 'Accept': 'application/json', + }, + }).then(res => res.json()); + + if (chaptersJson && chaptersJson.items) { + novel.chapters = chaptersJson.items.map( + (item: { chapter_name: string; chapter_id: string }) => ({ + name: item.chapter_name, + path: `chapter/${novelId}/${item.chapter_id}`, + releaseTime: null, + }), + ); + } + } catch (e) { + const chaptersMap = new Map(); + // Flexible Regex to handle JSON stream variations + const combinedRegex = + /\\?"chapter_id\\?":\\?"([^"]+)\\?",\\?"chapter_name\\?":\\?"([^"]+)\\?"/g; + let match; + while ((match = combinedRegex.exec(result)) !== null) { + const path = match[1]; + const name = match[2].replace(/\\"/g, '"'); + const fullPath = `chapter/${novelId}/${path}`; + if (!chaptersMap.has(fullPath)) { + chaptersMap.set(fullPath, { + name, + path: fullPath, + releaseTime: null, + }); + } + } + novel.chapters = Array.from(chaptersMap.values()); + } + + return novel; + } + + async parseChapter(chapterPath: string) { + const pathParts = chapterPath.replace('chapter/', '').split('/'); + const novelId = pathParts[0]; + const chapterId = pathParts[1]; + + const url = `${this.site}api-web/novels/${novelId}/chapters/${chapterId}`; + + try { + const json = await fetchApi(url, { + headers: { + 'Accept': 'application/json', + 'x-track-reading-progress': 'false', + }, + }).then(res => res.json()); + + if ( + json && + json.item && + json.item.chapterInfo && + json.item.chapterInfo.chapter_content + ) { + return json.item.chapterInfo.chapter_content; + } + } catch (e) { + const result = await fetchApi(`${this.site}${chapterPath}`).then(res => + res.text(), + ); + const contentRegex = /\\u003ch4\\u003e(.*)\\u003c\/p\\u003e/; + const match = result.match(contentRegex); + + if (match) { + let chapterHtml = match[0]; + chapterHtml = chapterHtml + .replace(/\\u003c/g, '<') + .replace(/\\u003e/g, '>') + .replace(/\\"/g, '"') + .replace(/\\n/g, '') + .replace(/\\t/g, '') + .replace(/\\r/g, '') + .replace(/\\\\/g, '\\'); + + const lastPTagIndex = chapterHtml.lastIndexOf('
'); + if (lastPTagIndex !== -1) { + chapterHtml = chapterHtml.substring(0, lastPTagIndex + 4); + } + return chapterHtml; + } + + const $ = parseHTML(result); + return $('.site-reading-copy').html() || 'Content not found or premium.'; + } + + return 'Content not found or premium.'; + } + + async searchNovels(searchTerm: string, page: number) { + const url = `${this.site}novels/search?keyword=${encodeURIComponent(searchTerm)}&page=${page}`; + const result = await fetchApi(url).then(res => res.text()); + const $ = parseHTML(result); + const novels: Plugin.NovelItem[] = []; + + $('article').each((i, el) => { + const title = $(el).find('h2').text().trim(); + const cover = $(el).find('img').attr('src'); + const href = $(el).find('a').attr('href'); + + if (title && href) { + novels.push({ + name: title, + cover, + path: href.substring(1), + }); + } + }); + + return novels; + } +} + +export default new NovelArrow(); diff --git a/public/static/src/en/novelarrow/icon.png b/public/static/src/en/novelarrow/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..318f8c04e84617447b4beff41f9f5cc478104ec5 GIT binary patch literal 766 zcmV