From 34fd5de183ce181163598bacd8b7e610112284d5 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Fri, 25 Sep 2026 20:04:53 +0800 Subject: [PATCH 1/5] fix(english/novelarrow): migrate plugin to novelping.com The site moved from novelarrow.com to novelping.com with a new platform (Next.js + api-web endpoints replaced by an Express frontend): listings now live under /sort/updates, search under /search, novels under /book/ with chapters served by the /ajax/chapter-archive endpoint, and chapter text in #chr-content. Bump major version: replacing the site/domain behavior is the breaking case named in AGENTS.md. Closes #2572 Co-Authored-By: Muse Spark --- plugins/english/novelarrow.ts | 218 ++++++++++++---------------------- 1 file changed, 77 insertions(+), 141 deletions(-) diff --git a/plugins/english/novelarrow.ts b/plugins/english/novelarrow.ts index ac236a1b3..7758747a4 100644 --- a/plugins/english/novelarrow.ts +++ b/plugins/english/novelarrow.ts @@ -7,25 +7,34 @@ 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 = 'https://novelping.com/'; + 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 +42,90 @@ class NovelArrow implements Plugin.PluginBase { return novels; } + async popularNovels(page: number) { + const url = `${this.site}sort/updates?page=${page}`; + const result = await fetchApi(url).then(res => res.text()); + 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()); const $ = parseHTML(result); - const novelId = novelPath.replace('novel/', '').replace(/^\//, ''); + const novelId = novelPath + .replace(/^\//, '') + .replace(/^book\//, '') + .split('/')[0]; - // Collect genres - let genres = $('meta[name="og:novel:genre"], meta[property="og:novel:genre"]').attr('content'); + // 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(); - 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 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 statusText = ( + $('meta[property="og:novel:status"]').attr('content') || '' + ).toLowerCase(); const novel: Plugin.SourceNovel = { path: novelPath, - name: $('meta[name="og:novel:novel_name"]').attr('content') || $('h1').first().text().trim(), + 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'), + author: $('meta[property="og:novel:author"]').attr('content'), status: - $('meta[name="og:novel:status"]').attr('content') === 'Ongoing' - ? NovelStatus.Ongoing - : NovelStatus.Completed, + statusText === 'ongoing' ? NovelStatus.Ongoing : NovelStatus.Completed, 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 chapter list is rendered through an ajax endpoint + const chaptersUrl = `${this.site}ajax/chapter-archive?novelId=${encodeURIComponent(novelId)}`; + const chaptersHtml = await fetchApi(chaptersUrl).then(res => res.text()); + 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/${novelId}/${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; - } + const result = await fetchApi(this.site + chapterPath.replace(/^\//, '')) + .then(res => res.text()) + .catch(() => ''); + 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 url = `${this.site}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; + return this.parseListing(result); } } From a15bb5777dacd95bd118e9e426560ab9ce73febe Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Fri, 25 Sep 2026 20:10:27 +0800 Subject: [PATCH 2/5] fix(english/novelarrow): send browser-like headers on every request Cloudflare serves UA-less requests a challenge page that parses to zero novels, which fails the CI live check on runner egress IPs. Follow the sibling-plugin convention (e.g. readfrom) with a Chrome User-Agent plus Referer and Accept-Language on all fetches. Related to #2572 Co-Authored-By: Muse Spark --- plugins/english/novelarrow.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/plugins/english/novelarrow.ts b/plugins/english/novelarrow.ts index 7758747a4..81aea2fe0 100644 --- a/plugins/english/novelarrow.ts +++ b/plugins/english/novelarrow.ts @@ -3,6 +3,13 @@ import { fetchApi } from '@libs/fetch'; import { Plugin } from '@/types/plugin'; import { NovelStatus } from '@libs/novelStatus'; +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: 'https://novelping.com/', + 'Accept-Language': 'en-US,en;q=0.9', +}; + class NovelArrow implements Plugin.PluginBase { id = 'novelarrow'; name = 'Novel Arrow'; @@ -44,14 +51,14 @@ class NovelArrow implements Plugin.PluginBase { async popularNovels(page: number) { const url = `${this.site}sort/updates?page=${page}`; - const result = await fetchApi(url).then(res => res.text()); + const result = await fetchApi(url, { headers }).then(res => res.text()); 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()); + const result = await fetchApi(url, { headers }).then(res => res.text()); const $ = parseHTML(result); const novelId = novelPath @@ -86,7 +93,9 @@ class NovelArrow implements Plugin.PluginBase { // The chapter list is rendered through an ajax endpoint const chaptersUrl = `${this.site}ajax/chapter-archive?novelId=${encodeURIComponent(novelId)}`; - const chaptersHtml = await fetchApi(chaptersUrl).then(res => res.text()); + const chaptersHtml = await fetchApi(chaptersUrl, { headers }).then(res => + res.text(), + ); const $$ = parseHTML(chaptersHtml); const chapters: Plugin.ChapterItem[] = []; @@ -110,7 +119,9 @@ class NovelArrow implements Plugin.PluginBase { } async parseChapter(chapterPath: string) { - const result = await fetchApi(this.site + chapterPath.replace(/^\//, '')) + const result = await fetchApi(this.site + chapterPath.replace(/^\//, ''), { + headers, + }) .then(res => res.text()) .catch(() => ''); const $ = parseHTML(result); @@ -124,7 +135,7 @@ class NovelArrow implements Plugin.PluginBase { async searchNovels(searchTerm: string, page: number) { const url = `${this.site}search?keyword=${encodeURIComponent(searchTerm)}&page=${page}`; - const result = await fetchApi(url).then(res => res.text()); + const result = await fetchApi(url, { headers }).then(res => res.text()); return this.parseListing(result); } } From 69fc511a62654d7b13e3462cb37bfc1c8ed8c998 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Fri, 25 Sep 2026 20:31:26 +0800 Subject: [PATCH 3/5] fix(english/novelarrow): tolerate old novel/chapter paths, propagate chapter errors Address review findings: existing library entries keep novel/ paths from the previous site, so derive the slug from either the old novel/ or the new book/ form and always request the new route, returning canonical book/ novel and chapter paths. Accept old chapter// chapter paths the same way. Also let chapter fetch errors propagate instead of converting them into a fake empty result. Related to #2572 Co-Authored-By: Muse Spark --- plugins/english/novelarrow.ts | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/plugins/english/novelarrow.ts b/plugins/english/novelarrow.ts index 81aea2fe0..339bfd89f 100644 --- a/plugins/english/novelarrow.ts +++ b/plugins/english/novelarrow.ts @@ -56,15 +56,20 @@ class NovelArrow implements Plugin.PluginBase { } async parseNovel(novelPath: string) { + // 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}`; // Ensure no double slashes in the URL - const url = this.site + novelPath.replace(/^\//, ''); + const url = this.site + canonicalPath; const result = await fetchApi(url, { headers }).then(res => res.text()); const $ = parseHTML(result); - const novelId = novelPath - .replace(/^\//, '') - .replace(/^book\//, '') - .split('/')[0]; + const novelId = slug; // Get the full summary from the paragraphs inside the description block const fullSummary = @@ -78,7 +83,7 @@ class NovelArrow implements Plugin.PluginBase { ).toLowerCase(); const novel: Plugin.SourceNovel = { - path: novelPath, + path: canonicalPath, name: $('meta[property="og:novel:novel_name"]').attr('content') || $('h3.title').first().text().trim(), @@ -119,11 +124,15 @@ class NovelArrow implements Plugin.PluginBase { } async parseChapter(chapterPath: string) { - const result = await fetchApi(this.site + chapterPath.replace(/^\//, ''), { + // Accept the previous `chapter//` form as well as the current + // `book//` form. + const cleanPath = chapterPath.replace(/^\//, ''); + const canonicalChapterPath = cleanPath.startsWith('chapter/') + ? `book/${cleanPath.replace(/^chapter\//, '')}` + : cleanPath; + const result = await fetchApi(this.site + canonicalChapterPath, { headers, - }) - .then(res => res.text()) - .catch(() => ''); + }).then(res => res.text()); const $ = parseHTML(result); const content = $('#chr-content'); From 20bbbc95f622960a1dc46eed5f2d685f7d8b8efe Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Fri, 25 Sep 2026 20:46:17 +0800 Subject: [PATCH 4/5] fix(english/novelarrow): throw on non-ok responses with status attached Route every fetch through a helper that throws on non-2xx carrying the HTTP status, so a refusal surfaces as an error the live check classifies INCONCLUSIVE (the documented runner-block handling) instead of parsing into a false empty-result FAIL. Related to #2572 Co-Authored-By: Muse Spark --- plugins/english/novelarrow.ts | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/plugins/english/novelarrow.ts b/plugins/english/novelarrow.ts index 339bfd89f..111483805 100644 --- a/plugins/english/novelarrow.ts +++ b/plugins/english/novelarrow.ts @@ -10,6 +10,19 @@ const headers = { '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'; @@ -51,7 +64,7 @@ class NovelArrow implements Plugin.PluginBase { async popularNovels(page: number) { const url = `${this.site}sort/updates?page=${page}`; - const result = await fetchApi(url, { headers }).then(res => res.text()); + const result = await fetchSite(url); return this.parseListing(result); } @@ -66,7 +79,7 @@ class NovelArrow implements Plugin.PluginBase { const canonicalPath = `book/${slug}`; // Ensure no double slashes in the URL const url = this.site + canonicalPath; - const result = await fetchApi(url, { headers }).then(res => res.text()); + const result = await fetchSite(url); const $ = parseHTML(result); const novelId = slug; @@ -98,9 +111,7 @@ class NovelArrow implements Plugin.PluginBase { // The chapter list is rendered through an ajax endpoint const chaptersUrl = `${this.site}ajax/chapter-archive?novelId=${encodeURIComponent(novelId)}`; - const chaptersHtml = await fetchApi(chaptersUrl, { headers }).then(res => - res.text(), - ); + const chaptersHtml = await fetchSite(chaptersUrl); const $$ = parseHTML(chaptersHtml); const chapters: Plugin.ChapterItem[] = []; @@ -130,9 +141,7 @@ class NovelArrow implements Plugin.PluginBase { const canonicalChapterPath = cleanPath.startsWith('chapter/') ? `book/${cleanPath.replace(/^chapter\//, '')}` : cleanPath; - const result = await fetchApi(this.site + canonicalChapterPath, { - headers, - }).then(res => res.text()); + const result = await fetchSite(this.site + canonicalChapterPath); const $ = parseHTML(result); const content = $('#chr-content'); @@ -144,7 +153,7 @@ class NovelArrow implements Plugin.PluginBase { async searchNovels(searchTerm: string, page: number) { const url = `${this.site}search?keyword=${encodeURIComponent(searchTerm)}&page=${page}`; - const result = await fetchApi(url, { headers }).then(res => res.text()); + const result = await fetchSite(url); return this.parseListing(result); } } From cca64d447adb3c6feef2d7d5d6ce7d8d2da0e727 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Fri, 25 Sep 2026 21:23:10 +0800 Subject: [PATCH 5/5] refactor(english/novelarrow): clean-coder pass, single-source domain and Unknown status Single-source the site URL in one module const used by both the class site and the Referer header. Default unknown novel statuses to NovelStatus.Unknown instead of Completed. Record the verified chapter-archive oldest-first ordering and the verified-sufficient single-meta author/genre lookups. Inline the dead novelId alias, drop the stale double-slash comment, and collapse the chapter-path canonicalization to one replace. Related to #2572 Co-Authored-By: Muse Spark --- plugins/english/novelarrow.ts | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/plugins/english/novelarrow.ts b/plugins/english/novelarrow.ts index 111483805..9d0fc9f56 100644 --- a/plugins/english/novelarrow.ts +++ b/plugins/english/novelarrow.ts @@ -3,10 +3,12 @@ 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: 'https://novelping.com/', + Referer: siteUrl, 'Accept-Language': 'en-US,en;q=0.9', }; @@ -27,7 +29,7 @@ class NovelArrow implements Plugin.PluginBase { id = 'novelarrow'; name = 'Novel Arrow'; icon = 'src/en/novelarrow/icon.png'; - site = 'https://novelping.com/'; + site = siteUrl; version = '2.0.0'; private toPath(href?: string) { @@ -77,13 +79,10 @@ class NovelArrow implements Plugin.PluginBase { .replace(/^(book|novel)\//, '') .split('/')[0]; const canonicalPath = `book/${slug}`; - // Ensure no double slashes in the URL const url = this.site + canonicalPath; const result = await fetchSite(url); const $ = parseHTML(result); - const novelId = slug; - // Get the full summary from the paragraphs inside the description block const fullSummary = $('#novel-description-content p') @@ -95,6 +94,15 @@ class NovelArrow implements Plugin.PluginBase { $('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; + } + const novel: Plugin.SourceNovel = { path: canonicalPath, name: @@ -102,15 +110,15 @@ class NovelArrow implements Plugin.PluginBase { $('h3.title').first().text().trim(), cover: $('meta[property="og:image"]').attr('content'), author: $('meta[property="og:novel:author"]').attr('content'), - status: - statusText === 'ongoing' ? NovelStatus.Ongoing : NovelStatus.Completed, + status, summary: fullSummary, genres: $('meta[property="og:novel:genre"]').attr('content'), chapters: [], }; - // The chapter list is rendered through an ajax endpoint - const chaptersUrl = `${this.site}ajax/chapter-archive?novelId=${encodeURIComponent(novelId)}`; + // 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[] = []; @@ -123,7 +131,7 @@ class NovelArrow implements Plugin.PluginBase { if (chapterId && name) { chapters.push({ name, - path: `book/${novelId}/${chapterId}`, + path: `book/${slug}/${chapterId}`, releaseTime: null, }); } @@ -137,10 +145,9 @@ class NovelArrow implements Plugin.PluginBase { async parseChapter(chapterPath: string) { // Accept the previous `chapter//` form as well as the current // `book//` form. - const cleanPath = chapterPath.replace(/^\//, ''); - const canonicalChapterPath = cleanPath.startsWith('chapter/') - ? `book/${cleanPath.replace(/^chapter\//, '')}` - : cleanPath; + const canonicalChapterPath = chapterPath + .replace(/^\//, '') + .replace(/^chapter\//, 'book/'); const result = await fetchSite(this.site + canonicalChapterPath); const $ = parseHTML(result); const content = $('#chr-content');