fix: Handle Fenrir Realm API Change (#2086)
* Update fenrirrealm.ts
* Update fenrirrealm.ts
* Update fenrirrealm.ts
* Improve novel status extraction and clean summary
Enhanced status parsing logic and cleaned up summary HTML.
* Enhance parseNovelFromApi for better data handling
Update parseNovelFromApi to clean HTML tags and set default status.
* feat: add Fenrir Realm plugin for novel fetching and parsing.
* feat: add Fenrir Realm plugin for novel fetching and parsing.
* feat: add Fenrir Realm plugin for fetching and parsing novels and chapters.
* feat: add Fenrir Realm plugin for novel browsing and reading
* feat: Add Fenrir Realm plugin for fetching and parsing novels and chapters.
* feat: add Fenrir Realm plugin for novel fetching and parsing.
* feat: Add Fenrir Realm plugin for fetching and parsing novels.
* feat: add Fenrir Realm plugin
* feat: add Fenrir Realm plugin
* Update auto-generated plugins index
* chore: delete file.
* feat: Add new Fenrir Realm plugin.
* feat: Add Fenrir Realm plugin and establish a new centralized plugin index.
* Delete a file.
* Fix Fenrir Realm plugin fallback slug for old library entries
* Enhance fenrirrealm plugin fallback to auto-heal renamed slugs using search API
* feat: Add Fenrir Realm plugin, Gunslinger novel data, and a related HTML parsing test script.
* Implement fuzzy search backoff to handle misspelled user queries in searchNovels
* Refactor Fenrir Realm plugin to fetch novel details directly from the undocumented JSON API, bypassing HTML scraping and Cloudflare rate-limiting issues
* Fix fenrirrealm plugin falling back to clean slug and migrating parseNovel to JSON API
* Fix fetching empty chapters by adopting exact novel slug formatting dictated by the fenrir realm JSON chapter payload instead of statically building chapter-{{id}}
* Auto-heal legacy broken chapter URLs in user libraries by dynamically resolving slugs via API fallback during parseChapter
* Enhance parseChapter auto-heal with exhaustive search-fallback to natively resolve heavily outdated overarching novel slugs
* Optimize search fallback heuristic to prevent Cloudflare 403 network request rate limits
* Intercept generic fetchApi crashes caused by Cloudflare 403 blocks with localized WebView Captcha-prompt error responses
* feat: add new gunslinger and fenrirrealm plugins, along with associated test and utility files.
* feat: Add Fenrir Realm plugin with associated test and support files.
* feat: Add Fenrir Realm plugin with associated test files and data.
* Distribute explicitly defined anti-Cloudflare fetch Headers globally to all internal APIs throughout the legacy auto-heal fallback routing
* Silence HTTP 500 SvelteKit crash exceptions in parseChapter to allow robust legacy URL auto-healing to trigger
* Add inline catch guards to ALL async calls in parseChapter auto-heal to prevent React Native fetch exceptions from aborting legacy URL recovery
* feat: Add Fenrir Realm plugin for 'Surviving as a Gunslinger in Wild West' novel, including associated data and test files.
* Refactor chapter parsing logic to improve reliability and handle potential changes in website structure.
* Refactor chapter parsing logic to improve error handling and fallback mechanism, utilizing .content-area class for HTML extraction.
* Update version number to 1.0.13 in FenrirRealmPlugin
* Refactor Fenrir Realm plugin for improved API calls
* Update Fenrir Realm version to 1.0.13
* feat(fenrir): rewrite parseChapter with sveltekit fallback and path healing
* feat(fenrir): robust parseChapter with prosemirror json fallback and path healing
* feat(fenrir): restore to official commit ef55446 (v1.0.16) [no-verify]
* feat: Add Fenrir Realm plugin and settings component, and remove various configuration files.
* feat: Introduce settings management UI and API, establish a plugin system, and remove obsolete configuration files.
* feat: Introduce a new plugin indexing system, add Fenrir Realm plugin and settings component, and remove various configuration files.
* feat: Add Fenrir Realm plugin and settings component, remove old configuration files, and update proxy.
* feat: Introduce a settings UI and plugin system, removing old configuration files and updating the proxy.
* Add missing file
Add missing file
* Add plugins index file with multiple language imports
This file imports various plugins from different languages and creates a PLUGINS array that exports them for use in the application.
* Delete index.ts
* feat: add Indra Translations and NovelRest plugins.
This commit is contained in:
+203
-72
@@ -16,12 +16,14 @@ type APINovel = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type APIChapter = {
|
type APIChapter = {
|
||||||
|
id: number;
|
||||||
locked: { price: number } | null;
|
locked: { price: number } | null;
|
||||||
group: null | {
|
group: null | {
|
||||||
index: number;
|
index: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
};
|
};
|
||||||
title: string;
|
title: string;
|
||||||
|
slug: string;
|
||||||
number: number;
|
number: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
@@ -38,7 +40,7 @@ class FenrirRealmPlugin implements Plugin.PluginBase {
|
|||||||
name = 'Fenrir Realm';
|
name = 'Fenrir Realm';
|
||||||
icon = 'src/en/fenrirrealm/icon.png';
|
icon = 'src/en/fenrirrealm/icon.png';
|
||||||
site = 'https://fenrirealm.com';
|
site = 'https://fenrirealm.com';
|
||||||
version = '1.0.12';
|
version = '1.0.13';
|
||||||
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
|
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
|
||||||
|
|
||||||
hideLocked = storage.get('hideLocked');
|
hideLocked = storage.get('hideLocked');
|
||||||
@@ -76,44 +78,72 @@ class FenrirRealmPlugin implements Plugin.PluginBase {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.data.map((r: APINovel) => this.parseNovelFromApi(r));
|
return (res.data || []).map((r: APINovel) => this.parseNovelFromApi(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||||
const html = await fetchApi(`${this.site}/series/${novelPath}`, {}).then(
|
let cleanNovelPath = novelPath;
|
||||||
r => r.text(),
|
let apiRes = await fetchApi(
|
||||||
|
`${this.site}/api/new/v2/series/${novelPath}/chapters`,
|
||||||
|
{},
|
||||||
);
|
);
|
||||||
const loadedCheerio = loadCheerio(html);
|
|
||||||
|
if (!apiRes.ok) {
|
||||||
|
const slugMatch = novelPath.match(/^\d+-(.+)$/);
|
||||||
|
let searchSlug = slugMatch ? slugMatch[1] : novelPath;
|
||||||
|
apiRes = await fetchApi(
|
||||||
|
`${this.site}/api/new/v2/series/${searchSlug}/chapters`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
cleanNovelPath = searchSlug;
|
||||||
|
|
||||||
|
if (!apiRes.ok) {
|
||||||
|
const words = searchSlug.replace(/-/g, ' ').split(' ');
|
||||||
|
const SearchStr = words.find(w => w.length > 3) || words[0];
|
||||||
|
const searchRes = await fetchApi(
|
||||||
|
`${this.site}/api/series/filter?page=1&per_page=20&search=${encodeURIComponent(SearchStr)}`,
|
||||||
|
).then(r => r.json());
|
||||||
|
|
||||||
|
if (searchRes.data && searchRes.data.length > 0) {
|
||||||
|
cleanNovelPath = searchRes.data[0].slug;
|
||||||
|
apiRes = await fetchApi(
|
||||||
|
`${this.site}/api/new/v2/series/${cleanNovelPath}/chapters`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiRes.ok) {
|
||||||
|
throw new Error(
|
||||||
|
'Novel not found. It may have been removed or its URL changed significantly.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seriesData = await fetchApi(
|
||||||
|
`${this.site}/api/new/v2/series/${cleanNovelPath}`,
|
||||||
|
).then(r => r.json());
|
||||||
|
const summaryCheerio = loadCheerio(seriesData.description || '');
|
||||||
|
|
||||||
const novel: Plugin.SourceNovel = {
|
const novel: Plugin.SourceNovel = {
|
||||||
path: novelPath,
|
path: cleanNovelPath,
|
||||||
name: loadedCheerio('h1.my-2').text(),
|
name: seriesData.title || '',
|
||||||
summary: loadedCheerio(
|
summary:
|
||||||
'div.overflow-hidden.transition-all.max-h-\\[108px\\] p',
|
summaryCheerio('p').length > 0
|
||||||
)
|
? summaryCheerio('p')
|
||||||
.map((i, el) => loadCheerio(el).text())
|
.map((i, el) => loadCheerio(el).text())
|
||||||
.get()
|
.get()
|
||||||
.join('\n\n'),
|
.join('\n\n')
|
||||||
|
: summaryCheerio.text() || '',
|
||||||
|
author: seriesData.user?.name || seriesData.user?.username || '',
|
||||||
|
cover: seriesData.cover
|
||||||
|
? this.site + '/' + seriesData.cover
|
||||||
|
: defaultCover,
|
||||||
|
genres: (seriesData.genres || []).map((g: any) => g.name).join(','),
|
||||||
|
status: seriesData.status || 'Unknown',
|
||||||
};
|
};
|
||||||
// novel.artist = '';
|
|
||||||
novel.author = loadedCheerio(
|
|
||||||
'div.flex-1 > div.mb-3 > a.inline-flex',
|
|
||||||
).text();
|
|
||||||
const coverMatch = html.match(/,cover:"storage\/(.+?)",cover_data_url/);
|
|
||||||
novel.cover = coverMatch
|
|
||||||
? this.site + '/storage/' + coverMatch[1]
|
|
||||||
: defaultCover;
|
|
||||||
novel.genres = loadedCheerio('div.flex-1 > div.flex:not(.mb-3, .mt-5) > a')
|
|
||||||
.map((i, el) => loadCheerio(el).text())
|
|
||||||
.toArray()
|
|
||||||
.join(',');
|
|
||||||
novel.status = loadedCheerio('div.flex-1 > div.mb-3 > span.rounded-md')
|
|
||||||
.first()
|
|
||||||
.text();
|
|
||||||
|
|
||||||
let chapters = await fetchApi(
|
let chapters = await apiRes.json();
|
||||||
this.site + '/api/novels/chapter-list/' + novelPath,
|
|
||||||
).then(r => r.json());
|
|
||||||
|
|
||||||
if (this.hideLocked) {
|
if (this.hideLocked) {
|
||||||
chapters = chapters.filter((c: APIChapter) => !c.locked?.price);
|
chapters = chapters.filter((c: APIChapter) => !c.locked?.price);
|
||||||
@@ -123,7 +153,7 @@ class FenrirRealmPlugin implements Plugin.PluginBase {
|
|||||||
.map((c: APIChapter) => ({
|
.map((c: APIChapter) => ({
|
||||||
name:
|
name:
|
||||||
(c.locked?.price ? '🔒 ' : '') +
|
(c.locked?.price ? '🔒 ' : '') +
|
||||||
(c.group?.index === null ? '' : 'Vol ' + c.group?.index + ' ') +
|
(c.group?.index == null ? '' : 'Vol ' + c.group?.index + ' ') +
|
||||||
'Chapter ' +
|
'Chapter ' +
|
||||||
c.number +
|
c.number +
|
||||||
(c.title && c.title.trim() != 'Chapter ' + c.number
|
(c.title && c.title.trim() != 'Chapter ' + c.number
|
||||||
@@ -131,11 +161,13 @@ class FenrirRealmPlugin implements Plugin.PluginBase {
|
|||||||
: ''),
|
: ''),
|
||||||
path:
|
path:
|
||||||
novelPath +
|
novelPath +
|
||||||
(c.group?.index === null ? '' : '/' + c.group?.slug) +
|
(c.group?.index == null ? '' : '/' + c.group?.slug) +
|
||||||
'/chapter-' +
|
'/' +
|
||||||
c.number,
|
(c.slug || 'chapter-' + c.number) +
|
||||||
|
'~~' +
|
||||||
|
c.id,
|
||||||
releaseTime: c.created_at,
|
releaseTime: c.created_at,
|
||||||
chapterNumber: c.number + (c.group?.index || 0) * 1000000000000,
|
chapterNumber: c.number + (c.group?.index || 0) * 10000,
|
||||||
}))
|
}))
|
||||||
.sort(
|
.sort(
|
||||||
(a: ChapterInfo, b: ChapterInfo) => a.chapterNumber - b.chapterNumber,
|
(a: ChapterInfo, b: ChapterInfo) => a.chapterNumber - b.chapterNumber,
|
||||||
@@ -144,31 +176,147 @@ class FenrirRealmPlugin implements Plugin.PluginBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async parseChapter(chapterPath: string): Promise<string> {
|
async parseChapter(chapterPath: string): Promise<string> {
|
||||||
const page = await fetchApi(this.site + '/series/' + chapterPath, {}).then(
|
const chapterId = chapterPath.split('~~')[1];
|
||||||
r => r.text(),
|
if (chapterId) {
|
||||||
);
|
const url = `${this.site}/api/new/v2/chapters/${chapterId}`;
|
||||||
const chapter = loadCheerio(page)('[id^="reader-area-"]');
|
const res = await fetchApi(url);
|
||||||
chapter
|
const json = await res.json();
|
||||||
.contents()
|
const content = json.content;
|
||||||
.filter((_, node: Node) => {
|
|
||||||
return node.type === 'comment';
|
|
||||||
})
|
|
||||||
.remove();
|
|
||||||
|
|
||||||
return chapter.html() || '';
|
if (content) {
|
||||||
|
const parsedContent = JSON.parse(content);
|
||||||
|
if (parsedContent.type === 'doc') {
|
||||||
|
return parsedContent.content
|
||||||
|
.map((node: any) => {
|
||||||
|
if (node.type === 'paragraph') {
|
||||||
|
const innerHtml =
|
||||||
|
node.content
|
||||||
|
?.map((c: any) => {
|
||||||
|
if (c.type === 'text') {
|
||||||
|
let text = c.text;
|
||||||
|
if (c.marks) {
|
||||||
|
for (const mark of c.marks) {
|
||||||
|
if (mark.type === 'bold') text = `<b>${text}</b>`;
|
||||||
|
if (mark.type === 'italic') text = `<i>${text}</i>`;
|
||||||
|
if (mark.type === 'underline')
|
||||||
|
text = `<u>${text}</u>`;
|
||||||
|
if (mark.type === 'strike')
|
||||||
|
text = `<strike>${text}</strike>`;
|
||||||
|
if (mark.type === 'link')
|
||||||
|
text = `<a href="${mark.attrs?.href}">${text}</a>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
if (c.type === 'hardBreak') return '<br>';
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.join('') || '';
|
||||||
|
return `<p>${innerHtml}</p>`;
|
||||||
|
}
|
||||||
|
if (node.type === 'heading') {
|
||||||
|
const level = node.attrs?.level || 1;
|
||||||
|
const innerHtml =
|
||||||
|
node.content?.map((c: any) => c.text).join('') || '';
|
||||||
|
return `<h${level}>${innerHtml}</h${level}>`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback or old method
|
||||||
|
const url = `${this.site}/series/${chapterPath.split('~~')[0]}`;
|
||||||
|
const result = await fetchApi(url);
|
||||||
|
const body = await result.text();
|
||||||
|
|
||||||
|
const loadedCheerio = loadCheerio(body);
|
||||||
|
|
||||||
|
let chapterText = loadedCheerio('div.content-area p')
|
||||||
|
.map((i, el) => `<p>${loadCheerio(el).html()}</p>`)
|
||||||
|
.get()
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
if (chapterText) {
|
||||||
|
return chapterText;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to SvelteKit JSON if HTML parsing fails or is empty
|
||||||
|
try {
|
||||||
|
const jsonUrl = `${this.site}/series/${chapterPath.split('~~')[0]}/__data.json?x-sveltekit-invalidated=001`;
|
||||||
|
const jsonRes = await fetchApi(jsonUrl);
|
||||||
|
const json = await jsonRes.json();
|
||||||
|
|
||||||
|
const nodes = json.nodes;
|
||||||
|
const data = nodes?.find((n: any) => n.type === 'data')?.data;
|
||||||
|
if (data) {
|
||||||
|
const contentStr = data.find(
|
||||||
|
(d: any) => typeof d === 'string' && d.includes('{"type":"doc"'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (contentStr) {
|
||||||
|
const contentJson = JSON.parse(contentStr);
|
||||||
|
if (contentJson.type === 'doc') {
|
||||||
|
chapterText = contentJson.content
|
||||||
|
.map((node: any) => {
|
||||||
|
if (node.type === 'paragraph') {
|
||||||
|
const innerHtml =
|
||||||
|
node.content
|
||||||
|
?.map((c: any) => {
|
||||||
|
if (c.type === 'text') {
|
||||||
|
let text = c.text;
|
||||||
|
if (c.marks) {
|
||||||
|
for (const mark of c.marks) {
|
||||||
|
if (mark.type === 'bold') text = `<b>${text}</b>`;
|
||||||
|
if (mark.type === 'italic')
|
||||||
|
text = `<i>${text}</i>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.join('') || '';
|
||||||
|
return `<p>${innerHtml}</p>`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return chapterText;
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchNovels(
|
async searchNovels(
|
||||||
searchTerm: string,
|
searchTerm: string,
|
||||||
pageNo: number,
|
pageNo: number,
|
||||||
): Promise<Plugin.NovelItem[]> {
|
): Promise<Plugin.NovelItem[]> {
|
||||||
return await fetchApi(
|
let url = `${this.site}/api/series/filter?page=${pageNo}&per_page=20&search=${encodeURIComponent(
|
||||||
`${this.site}/api/series/filter?page=${pageNo}&per_page=20&search=${encodeURIComponent(searchTerm)}`,
|
searchTerm,
|
||||||
)
|
)}`;
|
||||||
.then(r => r.json())
|
let res = await fetchApi(url).then(r => r.json());
|
||||||
.then(r =>
|
|
||||||
r.data.map((novel: APINovel) => this.parseNovelFromApi(novel)),
|
if (pageNo === 1 && (!res.data || res.data.length === 0)) {
|
||||||
);
|
const words = searchTerm.split(' ');
|
||||||
|
const fallbackTerm = words.find(w => w.length > 3) || words[0];
|
||||||
|
if (fallbackTerm && fallbackTerm !== searchTerm) {
|
||||||
|
url = `${this.site}/api/series/filter?page=${pageNo}&per_page=20&search=${encodeURIComponent(
|
||||||
|
fallbackTerm,
|
||||||
|
)}`;
|
||||||
|
res = await fetchApi(url).then(r => r.json());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (res.data || []).map((novel: APINovel) =>
|
||||||
|
this.parseNovelFromApi(novel),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
parseNovelFromApi(apiData: APINovel) {
|
parseNovelFromApi(apiData: APINovel) {
|
||||||
@@ -183,7 +331,7 @@ class FenrirRealmPlugin implements Plugin.PluginBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resolveUrl = (path: string, isNovel?: boolean) =>
|
resolveUrl = (path: string, isNovel?: boolean) =>
|
||||||
this.site + '/series/' + path;
|
this.site + '/series/' + path.split('~~')[0];
|
||||||
|
|
||||||
filters = {
|
filters = {
|
||||||
status: {
|
status: {
|
||||||
@@ -288,20 +436,3 @@ class FenrirRealmPlugin implements Plugin.PluginBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default new FenrirRealmPlugin();
|
export default new FenrirRealmPlugin();
|
||||||
|
|
||||||
type GenreData = {
|
|
||||||
name: string;
|
|
||||||
id: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
//paste into console on site to load
|
|
||||||
async function getUpdatedGenres() {
|
|
||||||
const data = await fetch(
|
|
||||||
'https://fenrirealm.com/api/novels/taxonomy/genres',
|
|
||||||
).then(d => d.json());
|
|
||||||
const genreData = data.map((g: GenreData) => ({
|
|
||||||
label: g.name,
|
|
||||||
value: g.id.toString(),
|
|
||||||
}));
|
|
||||||
console.log(JSON.stringify(genreData));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,286 +1,297 @@
|
|||||||
import { load } from 'cheerio';
|
import { load } from 'cheerio';
|
||||||
import { fetchApi } from '@libs/fetch';
|
import { fetchApi } from '@libs/fetch';
|
||||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||||
import { Plugin } from '@/types/plugin';
|
import { Plugin } from '@/types/plugin';
|
||||||
import { NovelStatus } from '@libs/novelStatus';
|
import { NovelStatus } from '@libs/novelStatus';
|
||||||
|
|
||||||
class IndraTranslations implements Plugin.PluginBase {
|
class IndraTranslations implements Plugin.PluginBase {
|
||||||
id = 'indratranslations';
|
id = 'indratranslations';
|
||||||
name = 'Indra Translations';
|
name = 'Indra Translations';
|
||||||
site = 'https://indratranslations.com';
|
site = 'https://indratranslations.com';
|
||||||
version = '1.2.0';
|
version = '1.2.0';
|
||||||
// icon = 'src/en/indratranslations/icon.png';
|
// icon = 'src/en/indratranslations/icon.png';
|
||||||
// customCSS = 'src/en/indratranslations/customCSS.css';
|
// customCSS = 'src/en/indratranslations/customCSS.css';
|
||||||
// (optional) Add these files to the repo and uncomment the lines above if you want an icon/custom CSS.
|
// (optional) Add these files to the repo and uncomment the lines above if you want an icon/custom CSS.
|
||||||
|
|
||||||
|
// Browser-like headers (important for Cloudflare-y sites)
|
||||||
// Browser-like headers (important for Cloudflare-y sites)
|
private headers = {
|
||||||
private headers = {
|
'User-Agent':
|
||||||
'User-Agent':
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36',
|
||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36',
|
Referer: this.site,
|
||||||
Referer: this.site,
|
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
'Accept-Language': 'en-US,en;q=0.9',
|
'Cache-Control': 'no-cache',
|
||||||
'Cache-Control': 'no-cache',
|
Pragma: 'no-cache',
|
||||||
Pragma: 'no-cache',
|
};
|
||||||
};
|
|
||||||
|
private async fetchHtml(url: string): Promise<string> {
|
||||||
private async fetchHtml(url: string): Promise<string> {
|
const res = await fetchApi(url, { headers: this.headers });
|
||||||
const res = await fetchApi(url, { headers: this.headers });
|
return await res.text();
|
||||||
return await res.text();
|
}
|
||||||
}
|
|
||||||
|
private absolute(url?: string): string | undefined {
|
||||||
private absolute(url?: string): string | undefined {
|
if (!url) return undefined;
|
||||||
if (!url) return undefined;
|
const u = String(url).trim();
|
||||||
const u = String(url).trim();
|
if (!u) return undefined;
|
||||||
if (!u) return undefined;
|
if (u.startsWith('http')) return u;
|
||||||
if (u.startsWith('http')) return u;
|
if (u.startsWith('//')) return 'https:' + u;
|
||||||
if (u.startsWith('//')) return 'https:' + u;
|
if (u.startsWith('/')) return this.site + u;
|
||||||
if (u.startsWith('/')) return this.site + u;
|
return this.site + '/' + u;
|
||||||
return this.site + '/' + u;
|
}
|
||||||
}
|
|
||||||
|
private clean(text: unknown): string {
|
||||||
private clean(text: unknown): string {
|
return String(text ?? '')
|
||||||
return String(text ?? '')
|
.replace(/\s+/g, ' ')
|
||||||
.replace(/\s+/g, ' ')
|
.trim();
|
||||||
.trim();
|
}
|
||||||
}
|
|
||||||
|
private chapterNum(name: string): number {
|
||||||
private chapterNum(name: string): number {
|
const m = String(name).match(/(\d+(\.\d+)?)/);
|
||||||
const m = String(name).match(/(\d+(\.\d+)?)/);
|
return m ? Number(m[1]) : 0;
|
||||||
return m ? Number(m[1]) : 0;
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
/**
|
* Indra can render results in different templates.
|
||||||
* Indra can render results in different templates.
|
* This tries multiple layouts and returns a single unified list.
|
||||||
* This tries multiple layouts and returns a single unified list.
|
*/
|
||||||
*/
|
private parseNovelCards($: ReturnType<typeof load>) {
|
||||||
private parseNovelCards($: ReturnType<typeof load>) {
|
const out: { name: string; path: string; cover?: string }[] = [];
|
||||||
const out: { name: string; path: string; cover?: string }[] = [];
|
const seen = new Set<string>();
|
||||||
const seen = new Set<string>();
|
|
||||||
|
const push = (name?: string, path?: string, cover?: string) => {
|
||||||
const push = (name?: string, path?: string, cover?: string) => {
|
const cleanName = this.clean(name);
|
||||||
const cleanName = this.clean(name);
|
const cleanPath = String(path || '')
|
||||||
const cleanPath = String(path || '').replace(this.site, '').trim();
|
.replace(this.site, '')
|
||||||
if (!cleanName || !cleanPath) return;
|
.trim();
|
||||||
if (!cleanPath.includes('/series/')) return;
|
if (!cleanName || !cleanPath) return;
|
||||||
|
if (!cleanPath.includes('/series/')) return;
|
||||||
// Normalize trailing slash for consistency
|
|
||||||
const normalized = cleanPath.endsWith('/') ? cleanPath : cleanPath + '/';
|
// Normalize trailing slash for consistency
|
||||||
if (seen.has(normalized)) return;
|
const normalized = cleanPath.endsWith('/') ? cleanPath : cleanPath + '/';
|
||||||
|
if (seen.has(normalized)) return;
|
||||||
seen.add(normalized);
|
|
||||||
out.push({
|
seen.add(normalized);
|
||||||
name: cleanName,
|
out.push({
|
||||||
path: normalized,
|
name: cleanName,
|
||||||
cover: cover ? this.absolute(cover) : undefined,
|
path: normalized,
|
||||||
});
|
cover: cover ? this.absolute(cover) : undefined,
|
||||||
};
|
});
|
||||||
|
};
|
||||||
// -------- Layout A (Madara-style): .page-item-detail ----------
|
|
||||||
$('.page-item-detail').each((_, el) => {
|
// -------- Layout A (Madara-style): .page-item-detail ----------
|
||||||
const a = $(el).find('a[href*="/series/"]').first();
|
$('.page-item-detail').each((_, el) => {
|
||||||
const href = a.attr('href') || '';
|
const a = $(el).find('a[href*="/series/"]').first();
|
||||||
const title =
|
const href = a.attr('href') || '';
|
||||||
this.clean(a.attr('title')) ||
|
const title =
|
||||||
this.clean($(el).find('h3 a').text()) ||
|
this.clean(a.attr('title')) ||
|
||||||
this.clean($(el).find('.post-title a').text());
|
this.clean($(el).find('h3 a').text()) ||
|
||||||
|
this.clean($(el).find('.post-title a').text());
|
||||||
const img =
|
|
||||||
$(el).find('img').attr('data-src') ||
|
const img =
|
||||||
$(el).find('img').attr('data-lazy-src') ||
|
$(el).find('img').attr('data-src') ||
|
||||||
$(el).find('img').attr('src');
|
$(el).find('img').attr('data-lazy-src') ||
|
||||||
|
$(el).find('img').attr('src');
|
||||||
if (href) push(title, href, img || undefined);
|
|
||||||
});
|
if (href) push(title, href, img || undefined);
|
||||||
|
});
|
||||||
// -------- Layout B (common search tabs): .c-tabs-item__content ----------
|
|
||||||
$('.c-tabs-item__content').each((_, el) => {
|
// -------- Layout B (common search tabs): .c-tabs-item__content ----------
|
||||||
const a =
|
$('.c-tabs-item__content').each((_, el) => {
|
||||||
$(el).find('a[href*="/series/"]').first() ||
|
const a =
|
||||||
$(el).find('.tab-thumb a[href*="/series/"]').first();
|
$(el).find('a[href*="/series/"]').first() ||
|
||||||
|
$(el).find('.tab-thumb a[href*="/series/"]').first();
|
||||||
const href = (a as any).attr?.('href') || '';
|
|
||||||
const title =
|
const href = (a as any).attr?.('href') || '';
|
||||||
this.clean($(el).find('.post-title a').text()) ||
|
const title =
|
||||||
this.clean($(el).find('.tab-summary .post-title a').text()) ||
|
this.clean($(el).find('.post-title a').text()) ||
|
||||||
this.clean((a as any).attr?.('title')) ||
|
this.clean($(el).find('.tab-summary .post-title a').text()) ||
|
||||||
this.clean((a as any).text?.());
|
this.clean((a as any).attr?.('title')) ||
|
||||||
|
this.clean((a as any).text?.());
|
||||||
const img =
|
|
||||||
$(el).find('img').attr('data-src') ||
|
const img =
|
||||||
$(el).find('img').attr('data-lazy-src') ||
|
$(el).find('img').attr('data-src') ||
|
||||||
$(el).find('img').attr('src');
|
$(el).find('img').attr('data-lazy-src') ||
|
||||||
|
$(el).find('img').attr('src');
|
||||||
if (href) push(title, href, img || undefined);
|
|
||||||
});
|
if (href) push(title, href, img || undefined);
|
||||||
|
});
|
||||||
// -------- Layout C (sometimes search results are in .row or .col wrappers) ----------
|
|
||||||
$('.row').each((_, el) => {
|
// -------- Layout C (sometimes search results are in .row or .col wrappers) ----------
|
||||||
const a = $(el).find('a[href*="/series/"]').first();
|
$('.row').each((_, el) => {
|
||||||
const href = a.attr('href') || '';
|
const a = $(el).find('a[href*="/series/"]').first();
|
||||||
if (!href) return;
|
const href = a.attr('href') || '';
|
||||||
|
if (!href) return;
|
||||||
const title =
|
|
||||||
this.clean($(el).find('h3 a').text()) ||
|
const title =
|
||||||
this.clean($(el).find('.post-title a').text()) ||
|
this.clean($(el).find('h3 a').text()) ||
|
||||||
this.clean(a.attr('title')) ||
|
this.clean($(el).find('.post-title a').text()) ||
|
||||||
this.clean(a.text());
|
this.clean(a.attr('title')) ||
|
||||||
|
this.clean(a.text());
|
||||||
const img =
|
|
||||||
$(el).find('img').attr('data-src') ||
|
const img =
|
||||||
$(el).find('img').attr('data-lazy-src') ||
|
$(el).find('img').attr('data-src') ||
|
||||||
$(el).find('img').attr('src');
|
$(el).find('img').attr('data-lazy-src') ||
|
||||||
|
$(el).find('img').attr('src');
|
||||||
push(title, href, img || undefined);
|
|
||||||
});
|
push(title, href, img || undefined);
|
||||||
|
});
|
||||||
// -------- Layout D (fallback: any anchor to /series/) ----------
|
|
||||||
// If everything else fails but links exist, still return something.
|
// -------- Layout D (fallback: any anchor to /series/) ----------
|
||||||
if (out.length === 0) {
|
// If everything else fails but links exist, still return something.
|
||||||
$('a[href*="/series/"]').each((_, el) => {
|
if (out.length === 0) {
|
||||||
const a = $(el);
|
$('a[href*="/series/"]').each((_, el) => {
|
||||||
const href = a.attr('href') || '';
|
const a = $(el);
|
||||||
if (!href) return;
|
const href = a.attr('href') || '';
|
||||||
|
if (!href) return;
|
||||||
const title = this.clean(a.attr('title')) || this.clean(a.text()) || 'Unknown';
|
|
||||||
|
const title =
|
||||||
// Try to find an image near the link
|
this.clean(a.attr('title')) || this.clean(a.text()) || 'Unknown';
|
||||||
const img =
|
|
||||||
a.find('img').attr('data-src') ||
|
// Try to find an image near the link
|
||||||
a.find('img').attr('data-lazy-src') ||
|
const img =
|
||||||
a.find('img').attr('src') ||
|
a.find('img').attr('data-src') ||
|
||||||
a.closest('*').find('img').first().attr('data-src') ||
|
a.find('img').attr('data-lazy-src') ||
|
||||||
a.closest('*').find('img').first().attr('data-lazy-src') ||
|
a.find('img').attr('src') ||
|
||||||
a.closest('*').find('img').first().attr('src');
|
a.closest('*').find('img').first().attr('data-src') ||
|
||||||
|
a.closest('*').find('img').first().attr('data-lazy-src') ||
|
||||||
push(title, href, img || undefined);
|
a.closest('*').find('img').first().attr('src');
|
||||||
});
|
|
||||||
}
|
push(title, href, img || undefined);
|
||||||
|
});
|
||||||
return out;
|
}
|
||||||
}
|
|
||||||
|
return out;
|
||||||
async popularNovels(pageNo: number) {
|
}
|
||||||
if (pageNo !== 1) return [];
|
|
||||||
const html = await this.fetchHtml(`${this.site}/series/`);
|
async popularNovels(pageNo: number) {
|
||||||
const $ = load(html);
|
if (pageNo !== 1) return [];
|
||||||
const parsed = this.parseNovelCards($);
|
const html = await this.fetchHtml(`${this.site}/series/`);
|
||||||
|
const $ = load(html);
|
||||||
return parsed.map((n) => ({
|
const parsed = this.parseNovelCards($);
|
||||||
name: n.name,
|
|
||||||
path: n.path,
|
return parsed.map(n => ({
|
||||||
cover: n.cover,
|
name: n.name,
|
||||||
}));
|
path: n.path,
|
||||||
}
|
cover: n.cover,
|
||||||
|
}));
|
||||||
async searchNovels(searchTerm: string, pageNo: number) {
|
}
|
||||||
if (pageNo !== 1) return [];
|
|
||||||
const url = `${this.site}/?s=${encodeURIComponent(searchTerm)}&post_type=wp-manga`;
|
async searchNovels(searchTerm: string, pageNo: number) {
|
||||||
const html = await this.fetchHtml(url);
|
if (pageNo !== 1) return [];
|
||||||
const $ = load(html);
|
const url = `${this.site}/?s=${encodeURIComponent(searchTerm)}&post_type=wp-manga`;
|
||||||
return this.parseNovelCards($);
|
const html = await this.fetchHtml(url);
|
||||||
}
|
const $ = load(html);
|
||||||
|
return this.parseNovelCards($);
|
||||||
async parseNovel(novelPath: string) {
|
}
|
||||||
const url = novelPath.startsWith('http') ? novelPath : this.site + novelPath;
|
|
||||||
const html = await this.fetchHtml(url);
|
async parseNovel(novelPath: string) {
|
||||||
const $ = load(html);
|
const url = novelPath.startsWith('http')
|
||||||
|
? novelPath
|
||||||
const title =
|
: this.site + novelPath;
|
||||||
this.clean($('h1.entry-title').text()) || this.clean($('h1').first().text()) || 'Unknown';
|
const html = await this.fetchHtml(url);
|
||||||
|
const $ = load(html);
|
||||||
const cover = this.absolute(
|
|
||||||
$('.summary_image img').attr('data-src') ||
|
const title =
|
||||||
$('.summary_image img').attr('data-lazy-src') ||
|
this.clean($('h1.entry-title').text()) ||
|
||||||
$('.summary_image img').attr('src'),
|
this.clean($('h1').first().text()) ||
|
||||||
);
|
'Unknown';
|
||||||
|
|
||||||
const summary =
|
const cover = this.absolute(
|
||||||
this.clean($('.summary__content').text()) ||
|
$('.summary_image img').attr('data-src') ||
|
||||||
this.clean($('.description-summary').text()) ||
|
$('.summary_image img').attr('data-lazy-src') ||
|
||||||
undefined;
|
$('.summary_image img').attr('src'),
|
||||||
|
);
|
||||||
let statusText = '';
|
|
||||||
$('.post-content_item').each((_, el) => {
|
const summary =
|
||||||
const label = this.clean($(el).find('.summary-heading').text()).toLowerCase();
|
this.clean($('.summary__content').text()) ||
|
||||||
if (label.includes('status')) {
|
this.clean($('.description-summary').text()) ||
|
||||||
statusText = this.clean($(el).find('.summary-content').text());
|
undefined;
|
||||||
}
|
|
||||||
});
|
let statusText = '';
|
||||||
|
$('.post-content_item').each((_, el) => {
|
||||||
const chapters: { name: string; path: string; chapterNumber?: number }[] = [];
|
const label = this.clean(
|
||||||
|
$(el).find('.summary-heading').text(),
|
||||||
$('li.wp-manga-chapter a').each((_, el) => {
|
).toLowerCase();
|
||||||
const href = $(el).attr('href');
|
if (label.includes('status')) {
|
||||||
if (!href) return;
|
statusText = this.clean($(el).find('.summary-content').text());
|
||||||
const name = this.clean($(el).text());
|
}
|
||||||
chapters.push({
|
});
|
||||||
name,
|
|
||||||
path: href.replace(this.site, ''),
|
const chapters: { name: string; path: string; chapterNumber?: number }[] =
|
||||||
chapterNumber: this.chapterNum(name),
|
[];
|
||||||
});
|
|
||||||
});
|
$('li.wp-manga-chapter a').each((_, el) => {
|
||||||
|
const href = $(el).attr('href');
|
||||||
if (chapters.length === 0) {
|
if (!href) return;
|
||||||
$('.wp-manga-chapter a').each((_, el) => {
|
const name = this.clean($(el).text());
|
||||||
const href = $(el).attr('href');
|
chapters.push({
|
||||||
if (!href) return;
|
name,
|
||||||
const name = this.clean($(el).text());
|
path: href.replace(this.site, ''),
|
||||||
chapters.push({
|
chapterNumber: this.chapterNum(name),
|
||||||
name,
|
});
|
||||||
path: href.replace(this.site, ''),
|
});
|
||||||
chapterNumber: this.chapterNum(name),
|
|
||||||
});
|
if (chapters.length === 0) {
|
||||||
});
|
$('.wp-manga-chapter a').each((_, el) => {
|
||||||
}
|
const href = $(el).attr('href');
|
||||||
|
if (!href) return;
|
||||||
chapters.sort((a, b) => (a.chapterNumber ?? 0) - (b.chapterNumber ?? 0));
|
const name = this.clean($(el).text());
|
||||||
|
chapters.push({
|
||||||
const statusLower = String(statusText).toLowerCase();
|
name,
|
||||||
const status =
|
path: href.replace(this.site, ''),
|
||||||
statusLower.includes('complete') || statusLower.includes('completed')
|
chapterNumber: this.chapterNum(name),
|
||||||
? NovelStatus.Completed
|
});
|
||||||
: NovelStatus.Ongoing;
|
});
|
||||||
|
}
|
||||||
return {
|
|
||||||
name: title,
|
chapters.sort((a, b) => (a.chapterNumber ?? 0) - (b.chapterNumber ?? 0));
|
||||||
path: novelPath.endsWith('/') ? novelPath : novelPath + '/',
|
|
||||||
cover,
|
const statusLower = String(statusText).toLowerCase();
|
||||||
summary,
|
const status =
|
||||||
status,
|
statusLower.includes('complete') || statusLower.includes('completed')
|
||||||
chapters,
|
? NovelStatus.Completed
|
||||||
};
|
: NovelStatus.Ongoing;
|
||||||
}
|
|
||||||
|
return {
|
||||||
async parseChapter(chapterPath: string) {
|
name: title,
|
||||||
const url = chapterPath.startsWith('http') ? chapterPath : this.site + chapterPath;
|
path: novelPath.endsWith('/') ? novelPath : novelPath + '/',
|
||||||
const html = await this.fetchHtml(url);
|
cover,
|
||||||
const $ = load(html);
|
summary,
|
||||||
|
status,
|
||||||
const content = $('.reading-content').first().length
|
chapters,
|
||||||
? $('.reading-content').first()
|
};
|
||||||
: $('.text-left').first().length
|
}
|
||||||
? $('.text-left').first()
|
|
||||||
: $('.entry-content').first();
|
async parseChapter(chapterPath: string) {
|
||||||
|
const url = chapterPath.startsWith('http')
|
||||||
if (!content.length) {
|
? chapterPath
|
||||||
return `\nUnable to load chapter content.\n\n`;
|
: this.site + chapterPath;
|
||||||
}
|
const html = await this.fetchHtml(url);
|
||||||
|
const $ = load(html);
|
||||||
content.find('script, style, ins, iframe, noscript').remove();
|
|
||||||
|
const content = $('.reading-content').first().length
|
||||||
return content.html() ?? '';
|
? $('.reading-content').first()
|
||||||
}
|
: $('.text-left').first().length
|
||||||
|
? $('.text-left').first()
|
||||||
filters: Filters = {
|
: $('.entry-content').first();
|
||||||
sort: {
|
|
||||||
label: 'Sort',
|
if (!content.length) {
|
||||||
value: 'Latest',
|
return `\nUnable to load chapter content.\n\n`;
|
||||||
options: [{ label: 'Latest', value: 'Latest' }],
|
}
|
||||||
type: FilterTypes.Picker,
|
|
||||||
},
|
content.find('script, style, ins, iframe, noscript').remove();
|
||||||
};
|
|
||||||
}
|
return content.html() ?? '';
|
||||||
|
}
|
||||||
export default new IndraTranslations();
|
|
||||||
|
filters: Filters = {
|
||||||
|
sort: {
|
||||||
|
label: 'Sort',
|
||||||
|
value: 'Latest',
|
||||||
|
options: [{ label: 'Latest', value: 'Latest' }],
|
||||||
|
type: FilterTypes.Picker,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new IndraTranslations();
|
||||||
|
|||||||
+242
-229
@@ -1,229 +1,242 @@
|
|||||||
import { fetchApi } from '@libs/fetch';
|
import { fetchApi } from '@libs/fetch';
|
||||||
import { Plugin } from '@/types/plugin';
|
import { Plugin } from '@/types/plugin';
|
||||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||||
import { defaultCover } from '@libs/defaultCover';
|
import { defaultCover } from '@libs/defaultCover';
|
||||||
import { NovelStatus } from '@libs/novelStatus';
|
import { NovelStatus } from '@libs/novelStatus';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NovelRest LNReader Plugin
|
* NovelRest LNReader Plugin
|
||||||
*
|
*
|
||||||
* This plugin allows LNReader to fetch novels from NovelRest (novelrest.vercel.app)
|
* This plugin allows LNReader to fetch novels from NovelRest (novelrest.vercel.app)
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Browse popular novels with pagination
|
* - Browse popular novels with pagination
|
||||||
* - Search novels
|
* - Search novels
|
||||||
* - Read chapters
|
* - Read chapters
|
||||||
* - Filter by status
|
* - Filter by status
|
||||||
* - Sort by latest/popular/rating/updated
|
* - Sort by latest/popular/rating/updated
|
||||||
*/
|
*/
|
||||||
class NovelRestPlugin implements Plugin.PluginBase {
|
class NovelRestPlugin implements Plugin.PluginBase {
|
||||||
id = 'novelrest';
|
id = 'novelrest';
|
||||||
name = 'NovelRest';
|
name = 'NovelRest';
|
||||||
icon = 'src/en/novelrest/icon.png';
|
icon = 'src/en/novelrest/icon.png';
|
||||||
site = 'https://novelrest.vercel.app';
|
site = 'https://novelrest.vercel.app';
|
||||||
apiBase = 'https://novelrest.vercel.app/api/lnreader';
|
apiBase = 'https://novelrest.vercel.app/api/lnreader';
|
||||||
version = '1.0.0';
|
version = '1.0.0';
|
||||||
|
|
||||||
filters: Filters = {
|
filters: Filters = {
|
||||||
status: {
|
status: {
|
||||||
type: FilterTypes.Picker,
|
type: FilterTypes.Picker,
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
value: '',
|
value: '',
|
||||||
options: [
|
options: [
|
||||||
{ label: 'All', value: '' },
|
{ label: 'All', value: '' },
|
||||||
{ label: 'Ongoing', value: 'ONGOING' },
|
{ label: 'Ongoing', value: 'ONGOING' },
|
||||||
{ label: 'Completed', value: 'COMPLETED' },
|
{ label: 'Completed', value: 'COMPLETED' },
|
||||||
{ label: 'Hiatus', value: 'HIATUS' },
|
{ label: 'Hiatus', value: 'HIATUS' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
sort: {
|
sort: {
|
||||||
type: FilterTypes.Picker,
|
type: FilterTypes.Picker,
|
||||||
label: 'Sort By',
|
label: 'Sort By',
|
||||||
value: 'latest',
|
value: 'latest',
|
||||||
options: [
|
options: [
|
||||||
{ label: 'Latest', value: 'latest' },
|
{ label: 'Latest', value: 'latest' },
|
||||||
{ label: 'Popular', value: 'popular' },
|
{ label: 'Popular', value: 'popular' },
|
||||||
{ label: 'Rating', value: 'rating' },
|
{ label: 'Rating', value: 'rating' },
|
||||||
{ label: 'Updated', value: 'updated' },
|
{ label: 'Updated', value: 'updated' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch popular/latest novels with pagination
|
* Fetch popular/latest novels with pagination
|
||||||
*/
|
*/
|
||||||
async popularNovels(
|
async popularNovels(
|
||||||
pageNo: number,
|
pageNo: number,
|
||||||
{ showLatestNovels, filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
{
|
||||||
): Promise<Plugin.NovelItem[]> {
|
showLatestNovels,
|
||||||
const novels: Plugin.NovelItem[] = [];
|
filters,
|
||||||
|
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||||
// Build query parameters
|
): Promise<Plugin.NovelItem[]> {
|
||||||
const params = new URLSearchParams();
|
const novels: Plugin.NovelItem[] = [];
|
||||||
params.set('page', pageNo.toString());
|
|
||||||
params.set('limit', '20');
|
// Build query parameters
|
||||||
|
const params = new URLSearchParams();
|
||||||
if (filters?.status?.value) {
|
params.set('page', pageNo.toString());
|
||||||
params.set('status', filters.status.value as string);
|
params.set('limit', '20');
|
||||||
}
|
|
||||||
|
if (filters?.status?.value) {
|
||||||
const sortBy = showLatestNovels ? 'latest' : (filters?.sort?.value as string || 'popular');
|
params.set('status', filters.status.value as string);
|
||||||
params.set('sort', sortBy);
|
}
|
||||||
|
|
||||||
try {
|
const sortBy = showLatestNovels
|
||||||
const url = `${this.apiBase}/novels?${params.toString()}`;
|
? 'latest'
|
||||||
const response = await fetchApi(url);
|
: (filters?.sort?.value as string) || 'popular';
|
||||||
const data = await response.json();
|
params.set('sort', sortBy);
|
||||||
|
|
||||||
if (data.novels && Array.isArray(data.novels)) {
|
try {
|
||||||
for (const novel of data.novels) {
|
const url = `${this.apiBase}/novels?${params.toString()}`;
|
||||||
novels.push({
|
const response = await fetchApi(url);
|
||||||
name: novel.title,
|
const data = await response.json();
|
||||||
path: novel.slug, // Just the slug, we'll build full path in parseNovel
|
|
||||||
cover: novel.coverImage || defaultCover,
|
if (data.novels && Array.isArray(data.novels)) {
|
||||||
});
|
for (const novel of data.novels) {
|
||||||
}
|
novels.push({
|
||||||
}
|
name: novel.title,
|
||||||
} catch (error) {
|
path: novel.slug, // Just the slug, we'll build full path in parseNovel
|
||||||
console.error('NovelRest: Error fetching popular novels:', error);
|
cover: novel.coverImage || defaultCover,
|
||||||
}
|
});
|
||||||
|
}
|
||||||
return novels;
|
}
|
||||||
}
|
} catch (error) {
|
||||||
|
console.error('NovelRest: Error fetching popular novels:', error);
|
||||||
/**
|
}
|
||||||
* Parse novel details and chapter list
|
|
||||||
*/
|
return novels;
|
||||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
}
|
||||||
const novel: Plugin.SourceNovel = {
|
|
||||||
path: novelPath,
|
/**
|
||||||
name: 'Untitled',
|
* Parse novel details and chapter list
|
||||||
};
|
*/
|
||||||
|
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||||
try {
|
const novel: Plugin.SourceNovel = {
|
||||||
// novelPath is just the slug
|
path: novelPath,
|
||||||
const slug = novelPath.replace(/^\/novels\//, ''); // Clean up if path prefix exists
|
name: 'Untitled',
|
||||||
|
};
|
||||||
const response = await fetchApi(`${this.apiBase}/novels/${slug}`);
|
|
||||||
const data = await response.json();
|
try {
|
||||||
|
// novelPath is just the slug
|
||||||
if (data) {
|
const slug = novelPath.replace(/^\/novels\//, ''); // Clean up if path prefix exists
|
||||||
novel.name = data.title || 'Untitled';
|
|
||||||
novel.author = data.author || '';
|
const response = await fetchApi(`${this.apiBase}/novels/${slug}`);
|
||||||
novel.cover = data.coverImage || defaultCover;
|
const data = await response.json();
|
||||||
novel.genres = Array.isArray(data.genres)
|
|
||||||
? data.genres.map((g: { name: string } | string) => typeof g === 'string' ? g : g.name).join(', ')
|
if (data) {
|
||||||
: '';
|
novel.name = data.title || 'Untitled';
|
||||||
novel.summary = data.description || '';
|
novel.author = data.author || '';
|
||||||
|
novel.cover = data.coverImage || defaultCover;
|
||||||
// Map status
|
novel.genres = Array.isArray(data.genres)
|
||||||
switch (data.status) {
|
? data.genres
|
||||||
case 'COMPLETED':
|
.map((g: { name: string } | string) =>
|
||||||
novel.status = NovelStatus.Completed;
|
typeof g === 'string' ? g : g.name,
|
||||||
break;
|
)
|
||||||
case 'ONGOING':
|
.join(', ')
|
||||||
novel.status = NovelStatus.Ongoing;
|
: '';
|
||||||
break;
|
novel.summary = data.description || '';
|
||||||
case 'HIATUS':
|
|
||||||
novel.status = NovelStatus.OnHiatus;
|
// Map status
|
||||||
break;
|
switch (data.status) {
|
||||||
default:
|
case 'COMPLETED':
|
||||||
novel.status = NovelStatus.Unknown;
|
novel.status = NovelStatus.Completed;
|
||||||
}
|
break;
|
||||||
|
case 'ONGOING':
|
||||||
// Parse chapters
|
novel.status = NovelStatus.Ongoing;
|
||||||
const chapters: Plugin.ChapterItem[] = [];
|
break;
|
||||||
|
case 'HIATUS':
|
||||||
if (data.chapters && Array.isArray(data.chapters)) {
|
novel.status = NovelStatus.OnHiatus;
|
||||||
for (const chapter of data.chapters) {
|
break;
|
||||||
chapters.push({
|
default:
|
||||||
name: chapter.title || `Chapter ${chapter.number}`,
|
novel.status = NovelStatus.Unknown;
|
||||||
path: `${slug}/${chapter.number}`, // slug/chapterNumber format
|
}
|
||||||
releaseTime: chapter.createdAt || undefined,
|
|
||||||
chapterNumber: chapter.number,
|
// Parse chapters
|
||||||
});
|
const chapters: Plugin.ChapterItem[] = [];
|
||||||
}
|
|
||||||
}
|
if (data.chapters && Array.isArray(data.chapters)) {
|
||||||
|
for (const chapter of data.chapters) {
|
||||||
novel.chapters = chapters;
|
chapters.push({
|
||||||
}
|
name: chapter.title || `Chapter ${chapter.number}`,
|
||||||
} catch (error) {
|
path: `${slug}/${chapter.number}`, // slug/chapterNumber format
|
||||||
console.error('NovelRest: Error parsing novel:', error);
|
releaseTime: chapter.createdAt || undefined,
|
||||||
}
|
chapterNumber: chapter.number,
|
||||||
|
});
|
||||||
return novel;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
novel.chapters = chapters;
|
||||||
* Parse chapter content
|
}
|
||||||
*/
|
} catch (error) {
|
||||||
async parseChapter(chapterPath: string): Promise<string> {
|
console.error('NovelRest: Error parsing novel:', error);
|
||||||
try {
|
}
|
||||||
// chapterPath format: "slug/chapterNumber"
|
|
||||||
const parts = chapterPath.split('/');
|
return novel;
|
||||||
const chapterNumber = parts.pop();
|
}
|
||||||
const slug = parts.join('/');
|
|
||||||
|
/**
|
||||||
const response = await fetchApi(`${this.apiBase}/novels/${slug}/chapters/${chapterNumber}`);
|
* Parse chapter content
|
||||||
const data = await response.json();
|
*/
|
||||||
|
async parseChapter(chapterPath: string): Promise<string> {
|
||||||
if (data && data.contentHtml) {
|
try {
|
||||||
return data.contentHtml;
|
// chapterPath format: "slug/chapterNumber"
|
||||||
}
|
const parts = chapterPath.split('/');
|
||||||
|
const chapterNumber = parts.pop();
|
||||||
return '<p>Chapter content could not be loaded.</p>';
|
const slug = parts.join('/');
|
||||||
} catch (error) {
|
|
||||||
console.error('NovelRest: Error parsing chapter:', error);
|
const response = await fetchApi(
|
||||||
return '<p>Error loading chapter content.</p>';
|
`${this.apiBase}/novels/${slug}/chapters/${chapterNumber}`,
|
||||||
}
|
);
|
||||||
}
|
const data = await response.json();
|
||||||
|
|
||||||
/**
|
if (data && data.contentHtml) {
|
||||||
* Search novels by term
|
return data.contentHtml;
|
||||||
*/
|
}
|
||||||
async searchNovels(
|
|
||||||
searchTerm: string,
|
return '<p>Chapter content could not be loaded.</p>';
|
||||||
pageNo: number,
|
} catch (error) {
|
||||||
): Promise<Plugin.NovelItem[]> {
|
console.error('NovelRest: Error parsing chapter:', error);
|
||||||
const novels: Plugin.NovelItem[] = [];
|
return '<p>Error loading chapter content.</p>';
|
||||||
|
}
|
||||||
try {
|
}
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.set('q', searchTerm);
|
/**
|
||||||
params.set('page', pageNo.toString());
|
* Search novels by term
|
||||||
params.set('limit', '20');
|
*/
|
||||||
|
async searchNovels(
|
||||||
const response = await fetchApi(`${this.apiBase}/novels?${params.toString()}`);
|
searchTerm: string,
|
||||||
const data = await response.json();
|
pageNo: number,
|
||||||
|
): Promise<Plugin.NovelItem[]> {
|
||||||
if (data.novels && Array.isArray(data.novels)) {
|
const novels: Plugin.NovelItem[] = [];
|
||||||
for (const novel of data.novels) {
|
|
||||||
novels.push({
|
try {
|
||||||
name: novel.title,
|
const params = new URLSearchParams();
|
||||||
path: novel.slug,
|
params.set('q', searchTerm);
|
||||||
cover: novel.coverImage || defaultCover,
|
params.set('page', pageNo.toString());
|
||||||
});
|
params.set('limit', '20');
|
||||||
}
|
|
||||||
}
|
const response = await fetchApi(
|
||||||
} catch (error) {
|
`${this.apiBase}/novels?${params.toString()}`,
|
||||||
console.error('NovelRest: Error searching novels:', error);
|
);
|
||||||
}
|
const data = await response.json();
|
||||||
|
|
||||||
return novels;
|
if (data.novels && Array.isArray(data.novels)) {
|
||||||
}
|
for (const novel of data.novels) {
|
||||||
|
novels.push({
|
||||||
/**
|
name: novel.title,
|
||||||
* Resolve full URL for novel or chapter
|
path: novel.slug,
|
||||||
*/
|
cover: novel.coverImage || defaultCover,
|
||||||
resolveUrl = (path: string, isNovel?: boolean): string => {
|
});
|
||||||
if (isNovel) {
|
}
|
||||||
return `${this.site}/novels/${path}`;
|
}
|
||||||
}
|
} catch (error) {
|
||||||
// For chapters, path is "slug/chapterNumber"
|
console.error('NovelRest: Error searching novels:', error);
|
||||||
return `${this.site}/novels/${path}`;
|
}
|
||||||
};
|
|
||||||
}
|
return novels;
|
||||||
|
}
|
||||||
export default new NovelRestPlugin();
|
|
||||||
|
/**
|
||||||
// trigger build
|
* Resolve full URL for novel or chapter
|
||||||
|
*/
|
||||||
|
resolveUrl = (path: string, isNovel?: boolean): string => {
|
||||||
|
if (isNovel) {
|
||||||
|
return `${this.site}/novels/${path}`;
|
||||||
|
}
|
||||||
|
// For chapters, path is "slug/chapterNumber"
|
||||||
|
return `${this.site}/novels/${path}`;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new NovelRestPlugin();
|
||||||
|
|
||||||
|
// trigger build
|
||||||
|
|||||||
Reference in New Issue
Block a user