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 000000000..ad706e23b Binary files /dev/null and b/public/static/src/en/novel7s/icon.png differ