From b66a5d49ad337c3ce0ec72488f147d42e2959b8e Mon Sep 17 00:00:00 2001 From: Shivam Dewangan <110464857+shivamdevs@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:22:22 +0530 Subject: [PATCH] feat(english): add novel7s extension --- plugins/english/novel7s.ts | 276 ++++++++++++++++++++++++++ public/static/src/en/novel7s/icon.png | Bin 0 -> 7603 bytes 2 files changed, 276 insertions(+) create mode 100644 plugins/english/novel7s.ts create mode 100644 public/static/src/en/novel7s/icon.png diff --git a/plugins/english/novel7s.ts b/plugins/english/novel7s.ts new file mode 100644 index 000000000..643700cad --- /dev/null +++ b/plugins/english/novel7s.ts @@ -0,0 +1,276 @@ +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { Filters, FilterTypes } from '@libs/filterInputs'; +import { load as loadCheerio } from 'cheerio'; +import { defaultCover } from '@libs/defaultCover'; +import { NovelStatus } from '@libs/novelStatus'; + +// ─── WP REST Types ──────────────────────────────────────────────────────────── + +type WPCategory = { + id: number; + name: string; + slug: string; + count: number; + description: string; // contains + link: string; +}; + +type WPPost = { + id: number; + slug: string; + link: string; + title: { rendered: string }; + content: { rendered: string }; + excerpt?: { rendered: string }; + categories: number[]; + date: string; +}; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const PAGE_SIZE = 18; + +/** Extract the pathname from a full URL, e.g. "/novel-slug/" */ +function toPath(url: string): string { + try { + return new URL(url).pathname; + } catch { + return url; + } +} + +/** Parse the cover image URL out of a WP category description field. */ +function coverFromDesc(description: string): string { + const m = description.match(/src=["']([^"'>]+)/); + return m ? m[1] : defaultCover; +} + +/** Map a WP category to a NovelItem. */ +function catToNovel(cat: WPCategory): Plugin.NovelItem { + return { + name: cat.name, + path: toPath(cat.link), + cover: coverFromDesc(cat.description), + }; +} + +/** Fetch categories from WP REST API with the given query params. */ +async function fetchCategories( + rest: string, + params: Record, +): Promise { + const qs = Object.entries(params) + .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) + .join('&'); + return fetchApi(`${rest}/categories?${qs}`).then(r => r.json()); +} + +// ─── Plugin ─────────────────────────────────────────────────────────────────── + +class Novel7sPlugin implements Plugin.PluginBase { + id = 'novel7s'; + name = 'Novel7s'; + icon = 'src/en/novel7s/icon.png'; + site = 'https://novel7s.com'; + rest = `${this.site}/wp-json/wp/v2`; + version = '1.0.0'; + + filters = { + sort: { + label: 'Sort by', + value: 'count', + options: [ + { label: 'Most chapters (popular)', value: 'count' }, + { label: 'Trending (views)', value: 'trending' }, + { label: 'Latest uploaded', value: 'id' }, + { label: 'A → Z', value: 'name_asc' }, + { label: 'Z → A', value: 'name_desc' }, + ], + type: FilterTypes.Picker, + }, + } satisfies Filters; + + // ── popularNovels ─────────────────────────────────────────────────────────── + + async popularNovels( + pageNo: number, + { + showLatestNovels, + filters, + }: Plugin.PopularNovelsOptions, + ): Promise { + const sort = showLatestNovels ? 'id' : (filters?.sort?.value ?? 'count'); + + // "Trending" still relies on the custom admin-ajax endpoint (view count based) + if (sort === 'trending') { + const offset = (pageNo - 1) * PAGE_SIZE; + const data = await fetchApi( + `${this.site}/wp-admin/admin-ajax.php?action=n7_load_more&type=trending&offset=${offset}`, + ).then(r => r.json()); + return (data.items ?? []).map( + (item: { name: string; url: string; cover: string }) => ({ + name: item.name, + path: toPath(item.url), + cover: item.cover || defaultCover, + }), + ); + } + + // A→Z / Z→A use orderby=name; count and id are always desc (most/newest first). + const isAlpha = sort === 'name_asc' || sort === 'name_desc'; + const orderby = isAlpha ? 'name' : sort; + const finalOrder = sort === 'name_asc' ? 'asc' : 'desc'; + + const cats = await fetchCategories(this.rest, { + orderby, + order: finalOrder, + per_page: PAGE_SIZE, + page: pageNo, + hide_empty: 1, + _fields: 'id,name,slug,count,description,link', + }); + + return cats.map(catToNovel); + } + + // ── parseNovel ────────────────────────────────────────────────────────────── + + async parseNovel(novelPath: string): Promise { + const slug = novelPath.replace(/^\/|\/$/g, ''); + + // Fetch category metadata: name, cover, chapter count, and category ID + const cats = await fetchCategories(this.rest, { + slug, + _fields: 'id,name,count,description', + }); + + if (!cats || cats.length === 0) { + return { path: novelPath, name: slug }; + } + + const cat = cats[0]; + + // Now fetch all chapters. WP REST max per_page = 100. + // Use X-WP-TotalPages to determine if we need more requests. + const chaptersUrl = `${this.rest}/posts?categories=${cat.id}&orderby=date&order=asc&per_page=100&_fields=id,title,slug,link,date,excerpt`; + const firstRes = await fetchApi(chaptersUrl); + const totalPages = parseInt( + firstRes.headers.get('X-WP-TotalPages') || '1', + 10, + ); + const firstChapters: WPPost[] = await firstRes.json(); + + const allPosts: WPPost[] = [...firstChapters]; + + // Fetch remaining pages in parallel if needed + if (totalPages > 1) { + const extra = await Promise.all( + Array.from({ length: totalPages - 1 }, (_, i) => + fetchApi(`${chaptersUrl}&page=${i + 2}`).then( + r => r.json() as Promise, + ), + ), + ); + extra.forEach(page => allPosts.push(...page)); + } + + const chapters: Plugin.ChapterItem[] = allPosts.map((post, i) => { + const title = post.title.rendered; + const numMatch = title.match(/Chapter\s+(\d+(?:\.\d+)?)/i); + return { + name: numMatch ? `Chapter ${numMatch[1]}` : title, + path: toPath(post.link), + chapterNumber: numMatch ? parseFloat(numMatch[1]) : i + 1, + releaseTime: post.date, + }; + }); + + let summary = ''; + if (allPosts.length > 0 && allPosts[0].excerpt) { + summary = loadCheerio(allPosts[0].excerpt.rendered).text().trim(); + // Remove chapter title repetition from start of summary if present + summary = summary + .replace(/^.*?Chapter\s+\d+(?:\.\d+)?(?:[:-]|\s)+/i, '') + .trim(); + } + + return { + path: novelPath, + name: cat.name, + cover: coverFromDesc(cat.description), + author: 'Novel7s', + summary, + status: NovelStatus.Completed, + chapters, + }; + } + + // ── parseChapter ──────────────────────────────────────────────────────────── + + async parseChapter(chapterPath: string): Promise { + const slug = chapterPath.replace(/^\/|\/$/g, ''); + + const posts: WPPost[] = await fetchApi( + `${this.rest}/posts?slug=${slug}&_fields=content`, + ).then(r => r.json()); + + if (!posts || posts.length === 0) return ''; + + const rawHtml = posts[0].content.rendered; + const $ = loadCheerio(rawHtml); + + // Remove the first

that only contains (repeated chapter title) + $('p:has(strong)').first().remove(); + + // Merge broken paragraphs (translated from the site's own JS fix) + const paragraphs = $('p').toArray(); + for (let i = 0; i < paragraphs.length - 1; i++) { + const current = paragraphs[i]; + const next = paragraphs[i + 1]; + + if ($(current).next()[0] !== next) continue; + + const text = $(current).text().trim(); + if (!text) continue; + + // If it ends like a complete sentence, keep separate + if (/[.!?…]["'"']?$/.test(text)) continue; + + $(current).append(' '); + $(current).append($(next).contents()); + $(next).remove(); + paragraphs.splice(i + 1, 1); + i--; + } + + return $.html(); + } + + // ── searchNovels ──────────────────────────────────────────────────────────── + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + // Guard: empty term would return the default category list, not search results. + if (!searchTerm.trim()) return []; + + // WP REST categories?search= searches novel names directly, returns covers. + const cats = await fetchCategories(this.rest, { + search: searchTerm.trim(), + per_page: PAGE_SIZE, + page: pageNo, + hide_empty: 1, + _fields: 'id,name,slug,count,description,link', + }); + + return cats.map(catToNovel); + } + + // ── resolveUrl ────────────────────────────────────────────────────────────── + + resolveUrl = (path: string): string => this.site + path; +} + +export default new Novel7sPlugin(); diff --git a/public/static/src/en/novel7s/icon.png b/public/static/src/en/novel7s/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ad706e23bcb10fc95217a84c0c7ee5650acd1645 GIT binary patch literal 7603 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