diff --git a/plugins/english/novelarrow.ts b/plugins/english/novelarrow.ts index ac236a1b3..9d0fc9f56 100644 --- a/plugins/english/novelarrow.ts +++ b/plugins/english/novelarrow.ts @@ -3,29 +3,60 @@ import { fetchApi } from '@libs/fetch'; import { Plugin } from '@/types/plugin'; import { NovelStatus } from '@libs/novelStatus'; +const siteUrl = 'https://novelping.com/'; + +const headers = { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + Referer: siteUrl, + 'Accept-Language': 'en-US,en;q=0.9', +}; + +// Throw on a non-ok response carrying the HTTP status, so a refusal +// surfaces as an error (classified INCONCLUSIVE by the live check) +// instead of parsing into an empty result. +async function fetchSite(url: string) { + const res = await fetchApi(url, { headers }); + if (!res.ok) { + throw Object.assign(new Error('Request failed: ' + res.status), { + status: res.status, + }); + } + return res.text(); +} + class NovelArrow implements Plugin.PluginBase { id = 'novelarrow'; name = 'Novel Arrow'; icon = 'src/en/novelarrow/icon.png'; - site = 'https://novelarrow.com/'; - version = '1.0.1'; + site = siteUrl; + version = '2.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); + private toPath(href?: string) { + if (!href) { + return ''; + } + try { + return new URL(href, this.site).pathname.replace(/^\//, ''); + } catch { + return href.replace(/^\//, ''); + } + } + + private parseListing(html: string) { + const $ = parseHTML(html); 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'); + $('.novel-title a').each((i, el) => { + const name = $(el).text().trim(); + const path = this.toPath($(el).attr('href')); + const cover = $(el).closest('.row').find('img.cover').attr('src'); - if (title && href) { + if (name && path) { novels.push({ - name: title, + name, cover, - path: href.substring(1), // Result: "novel/slug" + path, }); } }); @@ -33,163 +64,104 @@ class NovelArrow implements Plugin.PluginBase { return novels; } + async popularNovels(page: number) { + const url = `${this.site}sort/updates?page=${page}`; + const result = await fetchSite(url); + return this.parseListing(result); + } + 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()); + // Accept both the previous `novel/` paths (kept by existing + // library entries) and the current `book/` paths, and always + // request the current route. + const slug = novelPath + .replace(/^\//, '') + .replace(/^(book|novel)\//, '') + .split('/')[0]; + const canonicalPath = `book/${slug}`; + const url = this.site + canonicalPath; + const result = await fetchSite(url); const $ = parseHTML(result); - const novelId = novelPath.replace('novel/', '').replace(/^\//, ''); - - // Collect genres - let genres = $('meta[name="og:novel:genre"], 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(', '); + // Get the full summary from the paragraphs inside the description block + const fullSummary = + $('#novel-description-content p') + .map((i, el) => $(el).text().trim()) + .get() + .join('\n\n') || $('#novel-description-content').text().trim(); + + const statusText = ( + $('meta[property="og:novel:status"]').attr('content') || '' + ).toLowerCase(); + + // The single og:novel:author and og:novel:genre lookups were verified + // sufficient on the new markup; the old fallbacks are dropped. + let status = NovelStatus.Unknown; + if (statusText === 'ongoing') { + status = NovelStatus.Ongoing; + } else if (statusText === 'completed') { + status = NovelStatus.Completed; } - // Get the full summary from the paragraphs inside class .site-reading-copy - const fullSummary = $('.site-reading-copy p') - .map((i, el) => $(el).text().trim()) - .get() - .join('\n\n') || $('.site-reading-copy').text().trim(); - const novel: Plugin.SourceNovel = { - path: novelPath, - name: $('meta[name="og:novel:novel_name"]').attr('content') || $('h1').first().text().trim(), + path: canonicalPath, + name: + $('meta[property="og:novel:novel_name"]').attr('content') || + $('h3.title').first().text().trim(), cover: $('meta[property="og:image"]').attr('content'), - author: $('meta[name="og:novel:author"]').attr('content') || $('meta[name="author"]').attr('content'), - status: - $('meta[name="og:novel:status"]').attr('content') === 'Ongoing' - ? NovelStatus.Ongoing - : NovelStatus.Completed, + author: $('meta[property="og:novel:author"]').attr('content'), + status, summary: fullSummary, - genres: genres, + genres: $('meta[property="og:novel:genre"]').attr('content'), 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, - }); - } + // The archive serves oldest-first like the old ?sort=asc endpoint, + // so no reversal is needed. + const chaptersUrl = `${this.site}ajax/chapter-archive?novelId=${encodeURIComponent(slug)}`; + const chaptersHtml = await fetchSite(chaptersUrl); + const $$ = parseHTML(chaptersHtml); + const chapters: Plugin.ChapterItem[] = []; + + $$('li[data-chapter-item]').each((i, el) => { + const chapterId = $$(el).attr('data-chapter-id'); + const anchor = $$(el).find('a'); + const name = (anchor.attr('title') || anchor.text()).trim(); + + if (chapterId && name) { + chapters.push({ + name, + path: `book/${slug}/${chapterId}`, + releaseTime: null, + }); } - novel.chapters = Array.from(chaptersMap.values()); - } + }); + + novel.chapters = chapters; 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; - } + // Accept the previous `chapter//` form as well as the current + // `book//` form. + const canonicalChapterPath = chapterPath + .replace(/^\//, '') + .replace(/^chapter\//, 'book/'); + const result = await fetchSite(this.site + canonicalChapterPath); + const $ = parseHTML(result); + const content = $('#chr-content'); - const $ = parseHTML(result); - return $('.site-reading-copy').html() || 'Content not found or premium.'; - } + // Strip ad slots injected inside the chapter body + content.find('.js-ad-slot').remove(); - return 'Content not found or premium.'; + return content.html() || '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; + const url = `${this.site}search?keyword=${encodeURIComponent(searchTerm)}&page=${page}`; + const result = await fetchSite(url); + return this.parseListing(result); } }