diff --git a/plugins/arabic/galaxynovels.ts b/plugins/arabic/galaxynovels.ts new file mode 100644 index 000000000..7f3b98970 --- /dev/null +++ b/plugins/arabic/galaxynovels.ts @@ -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 { + const res = await fetchApi(url); + if (!res.ok) { + throw new Error(`Could not reach site (${res.status})`); + } + return res.text(); + } + + private async fetchJson(url: string): Promise { + const res = await fetchApi(url); + if (!res.ok) { + throw new Error(`Could not reach site (${res.status})`); + } + return res.json() as Promise; + } + + async popularNovels( + pageNo: number, + { + showLatestNovels, + filters, + }: Plugin.PopularNovelsOptions, + ): Promise { + 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 { + 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 = { + 'مستمرة': 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(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 { + 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(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 || '

Content not available.

'; + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + 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(); diff --git a/plugins/index.ts b/plugins/index.ts index 448606c58..2ccdb38b6 100644 --- a/plugins/index.ts +++ b/plugins/index.ts @@ -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, @@ -505,5 +506,6 @@ const PLUGINS: Plugin.PluginBase[] = [ p_249, p_250, p_251, + p_252, ]; export default PLUGINS; diff --git a/public/static/src/ar/galaxynovels/icon.png b/public/static/src/ar/galaxynovels/icon.png new file mode 100644 index 000000000..b219dc73f Binary files /dev/null and b/public/static/src/ar/galaxynovels/icon.png differ