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:
7ui77
2026-03-31 03:04:18 +07:00
committed by GitHub
parent d53258657e
commit 37313f5ffa
3 changed files with 742 additions and 587 deletions
+203 -72
View File
@@ -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));
}
+297 -286
View File
@@ -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
View File
@@ -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