From ea560bc5e6269e3ff8f0b888eddc33aa49fbff5c Mon Sep 17 00:00:00 2001 From: 7ui77 <99854073+7ui77@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:12:40 +0000 Subject: [PATCH] Add NovelArrow plugin --- plugins/english/novelarrow.ts | 199 ++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 plugins/english/novelarrow.ts diff --git a/plugins/english/novelarrow.ts b/plugins/english/novelarrow.ts new file mode 100644 index 000000000..afef2e279 --- /dev/null +++ b/plugins/english/novelarrow.ts @@ -0,0 +1,199 @@ +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 { + id = 'novelarrow'; + name = 'Novel Arrow'; + icon = 'https://novelarrow.com/favicon-32.png'; + site = 'https://novelarrow.com/'; + version = '0.0.1'; + + // Required headers to bypass Cloudflare and simulate a mobile browser + headers = { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', + 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Mobile Safari/537.36', + 'Referer': 'https://novelarrow.com/', + 'x-client-platform': 'web-mobile', + 'x-device-type': 'mobile', + 'x-version-app': 'web-mobile', + }; + + async popularNovels(page: number) { + const url = `${this.site}novels/latest?page=${page}`; + const result = await fetchApi(url, { headers: this.headers }).then(res => res.text()); + const $ = parseHTML(result); + const novels: any[] = []; + + $('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, { headers: this.headers }).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: any = { + 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: { + ...this.headers, + 'Accept': 'application/json', + } + }).then(res => res.json()); + + if (chaptersJson && chaptersJson.items) { + novel.chapters = chaptersJson.items.map((item: any) => ({ + 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: { + ...this.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}`, { headers: this.headers }).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, { headers: this.headers }).then(res => res.text()); + const $ = parseHTML(result); + const novels: any[] = []; + + $('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();