diff --git a/plugins/arabic/rewayatfans.ts b/plugins/arabic/rewayatfans.ts new file mode 100644 index 000000000..a5ee909d9 --- /dev/null +++ b/plugins/arabic/rewayatfans.ts @@ -0,0 +1,198 @@ +import { load as parseHTML } from 'cheerio'; +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; + +type WPPage = { + id: number; + title: { rendered: string }; + slug: string; + link: string; + content: { rendered: string }; + date: string; +}; + +class RewayatFans implements Plugin.PluginBase { + id = 'rewayatfans'; + name = 'روايات فانز'; + version = '3.0.0'; + icon = 'src/ar/rewayatfans/icon.png'; + site = 'https://rewayatfans.com/'; + + private async fetchJson(url: string): Promise { + const res = await fetchApi(url); + if (!res.ok) throw new Error(`Request failed: ${res.status}`); + return res.json() as Promise; + } + + private async fetchHtml(url: string): Promise { + const res = await fetchApi(url); + if (!res.ok) throw new Error(`Request failed: ${res.status}`); + return res.text(); + } + + async popularNovels( + page: number, + { showLatestNovels }: Plugin.PopularNovelsOptions, + ): Promise { + if (showLatestNovels) { + const pages = await this.fetchJson( + `${this.site}wp-json/wp/v2/pages?per_page=20&page=${page}&orderby=date&order=desc&_fields=slug,title`, + ); + return pages.map(p => ({ + name: this.extractNovelName(p.title.rendered), + path: p.slug, + cover: '', + })); + } + + const allNovels = await this.getAllNovels(); + const pageSize = 20; + const start = (page - 1) * pageSize; + return allNovels.slice(start, start + pageSize); + } + + private async getAllNovels(): Promise { + const seen = new Set(); + const novels: Plugin.NovelItem[] = []; + + let pg = 1; + let hasMore = true; + + while (hasMore) { + const pages = await this.fetchJson( + `${this.site}wp-json/wp/v2/pages?per_page=100&page=${pg}&_fields=slug,title`, + ); + + if (pages.length === 0) { + hasMore = false; + break; + } + + for (const page of pages) { + const novelName = this.extractNovelName(page.title.rendered); + if (novelName && !seen.has(novelName)) { + seen.add(novelName); + novels.push({ + name: novelName, + path: page.slug, + cover: '', + }); + } + } + + if (pages.length < 100) hasMore = false; + pg++; + } + + return novels; + } + + async parseNovel(novelPath: string): Promise { + const novel: Plugin.SourceNovel = { + path: novelPath, + name: '', + chapters: [], + }; + + const slugBase = novelPath.replace(/\/$/, '').split('/').pop() || novelPath; + const searchTerm = slugBase + .replace(/-\d+$/, '') + .replace(/-/g, ' '); + + let pg = 1; + let hasMore = true; + + while (hasMore) { + const pages = await this.fetchJson( + `${this.site}wp-json/wp/v2/pages?search=${encodeURIComponent(searchTerm)}&per_page=100&page=${pg}&_fields=slug,title,date`, + ); + + if (pages.length === 0) { + hasMore = false; + break; + } + + for (const page of pages) { + const postSlug = page.slug; + if (postSlug.startsWith(slugBase.replace(/-\d+$/, ''))) { + if (!novel.name) { + novel.name = this.extractNovelName(page.title.rendered); + } + const numMatch = postSlug.match(/(\d+)$/); + const chapterNum = numMatch ? parseInt(numMatch[1], 10) : 0; + + novel.chapters!.push({ + name: page.title.rendered, + path: postSlug, + chapterNumber: chapterNum, + releaseTime: page.date, + }); + } + } + + if (pages.length < 100) hasMore = false; + pg++; + } + + if (novel.chapters!.length > 0) { + novel.chapters!.sort((a, b) => (a.chapterNumber || 0) - (b.chapterNumber || 0)); + if (!novel.name && novel.chapters!.length > 0) { + novel.name = this.extractNovelName(novel.chapters![0].name); + } + } + + return novel; + } + + async parseChapter(chapterPath: string): Promise { + const pages = await this.fetchJson( + `${this.site}wp-json/wp/v2/pages?slug=${chapterPath}&_fields=content`, + ); + + const arr = Array.isArray(pages) ? pages : [pages]; + if (arr.length > 0 && arr[0].content?.rendered) { + const $ = parseHTML(arr[0].content.rendered); + $('script, style, .sharedaddy, .jp-relatedposts, .wp-block-spacer').remove(); + return $.html(); + } + + const html = await this.fetchHtml(`${this.site}${chapterPath}/`); + const $ = parseHTML(html); + const content = + $('article .entry-content, .post-content, .entry-content').html() || ''; + return content || '

المحتوى غير متاح.

'; + } + + async searchNovels( + searchTerm: string, + page: number, + ): Promise { + const pages = await this.fetchJson( + `${this.site}wp-json/wp/v2/pages?search=${encodeURIComponent(searchTerm)}&per_page=20&page=${page}&_fields=slug,title`, + ); + + const seen = new Set(); + const novels: Plugin.NovelItem[] = []; + + for (const page of pages) { + const novelName = this.extractNovelName(page.title.rendered); + if (novelName && !seen.has(novelName)) { + seen.add(novelName); + novels.push({ + name: novelName, + path: page.slug, + cover: '', + }); + } + } + + return novels; + } + + private extractNovelName(title: string): string { + const match = title.match(/^(.+?)\s+\d+$/); + return match ? match[1].trim() : title.trim(); + } +} + +export default new RewayatFans(); diff --git a/public/static/src/ar/rewayatfans/icon.png b/public/static/src/ar/rewayatfans/icon.png new file mode 100644 index 000000000..8015a6242 Binary files /dev/null and b/public/static/src/ar/rewayatfans/icon.png differ