Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
276 changes: 276 additions & 0 deletions plugins/arabic/galaxynovels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
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('src') || img.attr('data-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 cover = $('img.wor-cover-img').first().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();
2 changes: 2 additions & 0 deletions plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ import p_248 from '@plugins/vietnamese/lightnovelvn';
import p_249 from '@plugins/vietnamese/LNHako';
import p_250 from '@plugins/vietnamese/nettruyen';
import p_251 from '@plugins/vietnamese/truyenss';
import p_252 from '@plugins/arabic/galaxynovels';

const PLUGINS: Plugin.PluginBase[] = [
p_0,
Expand Down Expand Up @@ -505,5 +506,6 @@ const PLUGINS: Plugin.PluginBase[] = [
p_249,
p_250,
p_251,
p_252,
];
export default PLUGINS;
Binary file added public/static/src/ar/galaxynovels/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.