Files
lnreader-plugins/plugins/arabic/galaxynovels.ts
T
2026-07-18 18:12:30 +05:00

278 lines
7.7 KiB
TypeScript

import { load as loadCheerio } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { NovelStatus } from '@libs/novelStatus';
import { Filters, FilterTypes } from '@libs/filterInputs';
type ChapterJSON = {
id: number;
position: number;
number: string;
label: string;
title: string;
url: string;
content_api: string;
date_iso: string;
};
type ChaptersIndex = {
novel_id: number;
total: number;
chapters: ChapterJSON[];
};
type ChapterContentResponse = {
schema: number;
data: {
content_html: string;
display_title: string;
navigation: { next_url: string; previous_url: string };
};
};
class GalaxyNovels implements Plugin.PluginBase {
id = 'galaxynovels';
name = 'Galaxy Novels';
version = '1.0.0';
icon = 'src/ar/galaxynovels/icon.png';
site = 'https://galaxynovels.com/';
filters = {
sort: {
label: 'Sort By',
value: 'popular',
options: [
{ label: 'Most Popular', value: 'popular' },
{ label: 'Newest', value: 'new' },
{ label: 'Recently Updated', value: 'recent' },
],
type: FilterTypes.Picker,
},
period: {
label: 'Period',
value: 'month',
options: [
{ label: 'Month', value: 'month' },
{ label: 'Week', value: 'week' },
{ label: 'All Time', value: 'all' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
private baseUrl = 'https://galaxynovels.com';
private async fetchHtml(url: string): Promise<string> {
const res = await fetchApi(url);
if (!res.ok) {
throw new Error(`Could not reach site (${res.status})`);
}
return res.text();
}
private async fetchJson<T>(url: string): Promise<T> {
const res = await fetchApi(url);
if (!res.ok) {
throw new Error(`Could not reach site (${res.status})`);
}
return res.json() as Promise<T>;
}
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
const sort = showLatestNovels ? 'new' : filters.sort.value;
const period = filters.period.value;
let url: string;
if (sort === 'new') {
url = `${this.baseUrl}/novels/?sort=new&page=${pageNo}`;
} else if (sort === 'recent') {
url = `${this.baseUrl}/recent/?page=${pageNo}`;
} else {
url = `${this.baseUrl}/novels/?sort=popular&period=${period}&page=${pageNo}`;
}
const html = await this.fetchHtml(url);
const $ = loadCheerio(html);
const novels: Plugin.NovelItem[] = [];
$('article.wor-novel-card').each((_, el) => {
const $el = $(el);
const coverLink = $el.find('a.wor-novel-card__cover');
const href = coverLink.attr('href');
const img = $el.find('img.wor-cover-img');
const cover =
img.attr('data-src') || img.attr('src') || undefined;
const title = $el.find('h3 a').text().trim();
if (!href || !title) return;
const path = new URL(href, this.site).pathname;
novels.push({
name: title,
path,
cover,
});
});
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const url = `${this.baseUrl}${novelPath}`;
const html = await this.fetchHtml(url);
const $ = loadCheerio(html);
const title = $('h1').first().text().trim();
const img = $('img.wor-cover-img').first();
const cover = img.attr('data-src') || img.attr('src');
const author = $('p.wor-single-hero__meta-text span').text().trim();
const summary = $('.wor-single-summary__text').text().trim();
const genres: string[] = [];
$('a.wor-tag-pill').each((_, el) => {
genres.push($(el).text().trim());
});
const statusText = $('span.wor-cover-status').text().trim();
const statusMap: Record<string, string> = {
'مستمرة': NovelStatus.Ongoing,
'مكتملة': NovelStatus.Completed,
'متوقفة': NovelStatus.OnHiatus,
};
const status = statusMap[statusText] || NovelStatus.Unknown;
const chaptersContainer = $('[data-wor-chapters-container]');
const chaptersIndexUrl = chaptersContainer.attr('data-index-url');
let chapters: Plugin.ChapterItem[] = [];
if (chaptersIndexUrl) {
try {
const indexUrl = chaptersIndexUrl.startsWith('http')
? chaptersIndexUrl
: `${this.baseUrl}${chaptersIndexUrl}`;
const index = await this.fetchJson<ChaptersIndex>(indexUrl);
chapters = index.chapters.map(ch => ({
name: ch.label + (ch.title ? `: ${ch.title}` : ''),
path: `${novelPath}chapter-${ch.id}/`,
chapterNumber: ch.position,
releaseTime: ch.date_iso?.split('T')[0] || '',
page: String(ch.id),
}));
} catch {
// fallback to HTML parsing
}
}
if (chapters.length === 0) {
$('article.wor-novel-chapter-item').each((_, el) => {
const $el = $(el);
const chapterLink = $el.find('h3 a').attr('href') || $el.find('a.wor-novel-chapter-item__num').attr('href');
const chapterName = $el.find('h3 a').text().trim() || $el.find('a.wor-novel-chapter-item__num').text().trim();
const chapterId = $el.attr('data-chapter-id');
const timeEl = $el.find('time');
const releaseTime = timeEl.attr('datetime')?.split('T')[0] || '';
if (!chapterLink) return;
const path = chapterId
? `${novelPath}chapter-${chapterId}/`
: new URL(chapterLink, this.site).pathname;
const numMatch = path.match(/chapter-(\d+)/);
const chapterNumber = numMatch ? parseInt(numMatch[1]) : 0;
chapters.push({
name: chapterName,
path,
chapterNumber,
releaseTime,
page: chapterId || '',
});
});
}
return {
path: novelPath,
name: title,
cover,
author: author || 'Unknown',
genres: genres.join(', '),
summary,
status,
chapters,
};
}
async parseChapter(chapterPath: string): Promise<string> {
const idMatch = chapterPath.match(/chapter-(\d+)/);
const chapterId = idMatch?.[1];
if (chapterId) {
const apiUrl = `${this.baseUrl}/wp-json/wor-reader-app/v1/chapters/${chapterId}`;
try {
const response = await this.fetchJson<ChapterContentResponse>(apiUrl);
if (response.data?.content_html) {
return response.data.content_html;
}
} catch {
// fallback to HTML
}
}
const url = `${this.baseUrl}${chapterPath}`;
const html = await this.fetchHtml(url);
const $ = loadCheerio(html);
const content =
$('article.wor-chapter-content, .wor-chapter-text, .entry-content').html();
return content || '<p>Content not available.</p>';
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
const manifestUrl =
'https://galaxynovels.com/wp-content/uploads/wor-reader-cache/search/manifest.json';
const manifest = await this.fetchJson<{
index: string;
}>(manifestUrl);
const searchIndex = await this.fetchJson<{
items: {
t: string;
u: string;
c: string;
s: string;
}[];
}>(manifest.index);
const term = searchTerm.toLowerCase();
const filtered = searchIndex.items.filter(
n =>
n.t.toLowerCase().includes(term) ||
n.s.toLowerCase().includes(term),
);
const limit = 20;
const offset = (pageNo - 1) * limit;
return filtered.slice(offset, offset + limit).map(novel => ({
name: novel.t,
path: novel.u,
cover: novel.c.startsWith('http')
? novel.c
: `${this.baseUrl}${novel.c}`,
}));
}
}
export default new GalaxyNovels();