Skip to content

Commit ece0643

Browse files
authored
feat(english): add novel7s extension (#2430)
1 parent 9fbb41b commit ece0643

2 files changed

Lines changed: 276 additions & 0 deletions

File tree

plugins/english/novel7s.ts

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import { fetchApi } from '@libs/fetch';
2+
import { Plugin } from '@/types/plugin';
3+
import { Filters, FilterTypes } from '@libs/filterInputs';
4+
import { load as loadCheerio } from 'cheerio';
5+
import { defaultCover } from '@libs/defaultCover';
6+
import { NovelStatus } from '@libs/novelStatus';
7+
8+
// ─── WP REST Types ────────────────────────────────────────────────────────────
9+
10+
type WPCategory = {
11+
id: number;
12+
name: string;
13+
slug: string;
14+
count: number;
15+
description: string; // contains <img src="cover-url" />
16+
link: string;
17+
};
18+
19+
type WPPost = {
20+
id: number;
21+
slug: string;
22+
link: string;
23+
title: { rendered: string };
24+
content: { rendered: string };
25+
excerpt?: { rendered: string };
26+
categories: number[];
27+
date: string;
28+
};
29+
30+
// ─── Helpers ──────────────────────────────────────────────────────────────────
31+
32+
const PAGE_SIZE = 18;
33+
34+
/** Extract the pathname from a full URL, e.g. "/novel-slug/" */
35+
function toPath(url: string): string {
36+
try {
37+
return new URL(url).pathname;
38+
} catch {
39+
return url;
40+
}
41+
}
42+
43+
/** Parse the cover image URL out of a WP category description field. */
44+
function coverFromDesc(description: string): string {
45+
const m = description.match(/src=["']([^"'>]+)/);
46+
return m ? m[1] : defaultCover;
47+
}
48+
49+
/** Map a WP category to a NovelItem. */
50+
function catToNovel(cat: WPCategory): Plugin.NovelItem {
51+
return {
52+
name: cat.name,
53+
path: toPath(cat.link),
54+
cover: coverFromDesc(cat.description),
55+
};
56+
}
57+
58+
/** Fetch categories from WP REST API with the given query params. */
59+
async function fetchCategories(
60+
rest: string,
61+
params: Record<string, string | number>,
62+
): Promise<WPCategory[]> {
63+
const qs = Object.entries(params)
64+
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
65+
.join('&');
66+
return fetchApi(`${rest}/categories?${qs}`).then(r => r.json());
67+
}
68+
69+
// ─── Plugin ───────────────────────────────────────────────────────────────────
70+
71+
class Novel7sPlugin implements Plugin.PluginBase {
72+
id = 'novel7s';
73+
name = 'Novel7s';
74+
icon = 'src/en/novel7s/icon.png';
75+
site = 'https://novel7s.com';
76+
rest = `${this.site}/wp-json/wp/v2`;
77+
version = '1.0.0';
78+
79+
filters = {
80+
sort: {
81+
label: 'Sort by',
82+
value: 'count',
83+
options: [
84+
{ label: 'Most chapters (popular)', value: 'count' },
85+
{ label: 'Trending (views)', value: 'trending' },
86+
{ label: 'Latest uploaded', value: 'id' },
87+
{ label: 'A → Z', value: 'name_asc' },
88+
{ label: 'Z → A', value: 'name_desc' },
89+
],
90+
type: FilterTypes.Picker,
91+
},
92+
} satisfies Filters;
93+
94+
// ── popularNovels ───────────────────────────────────────────────────────────
95+
96+
async popularNovels(
97+
pageNo: number,
98+
{
99+
showLatestNovels,
100+
filters,
101+
}: Plugin.PopularNovelsOptions<typeof this.filters>,
102+
): Promise<Plugin.NovelItem[]> {
103+
const sort = showLatestNovels ? 'id' : (filters?.sort?.value ?? 'count');
104+
105+
// "Trending" still relies on the custom admin-ajax endpoint (view count based)
106+
if (sort === 'trending') {
107+
const offset = (pageNo - 1) * PAGE_SIZE;
108+
const data = await fetchApi(
109+
`${this.site}/wp-admin/admin-ajax.php?action=n7_load_more&type=trending&offset=${offset}`,
110+
).then(r => r.json());
111+
return (data.items ?? []).map(
112+
(item: { name: string; url: string; cover: string }) => ({
113+
name: item.name,
114+
path: toPath(item.url),
115+
cover: item.cover || defaultCover,
116+
}),
117+
);
118+
}
119+
120+
// A→Z / Z→A use orderby=name; count and id are always desc (most/newest first).
121+
const isAlpha = sort === 'name_asc' || sort === 'name_desc';
122+
const orderby = isAlpha ? 'name' : sort;
123+
const finalOrder = sort === 'name_asc' ? 'asc' : 'desc';
124+
125+
const cats = await fetchCategories(this.rest, {
126+
orderby,
127+
order: finalOrder,
128+
per_page: PAGE_SIZE,
129+
page: pageNo,
130+
hide_empty: 1,
131+
_fields: 'id,name,slug,count,description,link',
132+
});
133+
134+
return cats.map(catToNovel);
135+
}
136+
137+
// ── parseNovel ──────────────────────────────────────────────────────────────
138+
139+
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
140+
const slug = novelPath.replace(/^\/|\/$/g, '');
141+
142+
// Fetch category metadata: name, cover, chapter count, and category ID
143+
const cats = await fetchCategories(this.rest, {
144+
slug,
145+
_fields: 'id,name,count,description',
146+
});
147+
148+
if (!cats || cats.length === 0) {
149+
return { path: novelPath, name: slug };
150+
}
151+
152+
const cat = cats[0];
153+
154+
// Now fetch all chapters. WP REST max per_page = 100.
155+
// Use X-WP-TotalPages to determine if we need more requests.
156+
const chaptersUrl = `${this.rest}/posts?categories=${cat.id}&orderby=date&order=asc&per_page=100&_fields=id,title,slug,link,date,excerpt`;
157+
const firstRes = await fetchApi(chaptersUrl);
158+
const totalPages = parseInt(
159+
firstRes.headers.get('X-WP-TotalPages') || '1',
160+
10,
161+
);
162+
const firstChapters: WPPost[] = await firstRes.json();
163+
164+
const allPosts: WPPost[] = [...firstChapters];
165+
166+
// Fetch remaining pages in parallel if needed
167+
if (totalPages > 1) {
168+
const extra = await Promise.all(
169+
Array.from({ length: totalPages - 1 }, (_, i) =>
170+
fetchApi(`${chaptersUrl}&page=${i + 2}`).then(
171+
r => r.json() as Promise<WPPost[]>,
172+
),
173+
),
174+
);
175+
extra.forEach(page => allPosts.push(...page));
176+
}
177+
178+
const chapters: Plugin.ChapterItem[] = allPosts.map((post, i) => {
179+
const title = post.title.rendered;
180+
const numMatch = title.match(/Chapter\s+(\d+(?:\.\d+)?)/i);
181+
return {
182+
name: numMatch ? `Chapter ${numMatch[1]}` : title,
183+
path: toPath(post.link),
184+
chapterNumber: numMatch ? parseFloat(numMatch[1]) : i + 1,
185+
releaseTime: post.date,
186+
};
187+
});
188+
189+
let summary = '';
190+
if (allPosts.length > 0 && allPosts[0].excerpt) {
191+
summary = loadCheerio(allPosts[0].excerpt.rendered).text().trim();
192+
// Remove chapter title repetition from start of summary if present
193+
summary = summary
194+
.replace(/^.*?Chapter\s+\d+(?:\.\d+)?(?:[:-]|\s)+/i, '')
195+
.trim();
196+
}
197+
198+
return {
199+
path: novelPath,
200+
name: cat.name,
201+
cover: coverFromDesc(cat.description),
202+
author: 'Novel7s',
203+
summary,
204+
status: NovelStatus.Completed,
205+
chapters,
206+
};
207+
}
208+
209+
// ── parseChapter ────────────────────────────────────────────────────────────
210+
211+
async parseChapter(chapterPath: string): Promise<string> {
212+
const slug = chapterPath.replace(/^\/|\/$/g, '');
213+
214+
const posts: WPPost[] = await fetchApi(
215+
`${this.rest}/posts?slug=${slug}&_fields=content`,
216+
).then(r => r.json());
217+
218+
if (!posts || posts.length === 0) return '';
219+
220+
const rawHtml = posts[0].content.rendered;
221+
const $ = loadCheerio(rawHtml);
222+
223+
// Remove the first <p> that only contains <strong> (repeated chapter title)
224+
$('p:has(strong)').first().remove();
225+
226+
// Merge broken paragraphs (translated from the site's own JS fix)
227+
const paragraphs = $('p').toArray();
228+
for (let i = 0; i < paragraphs.length - 1; i++) {
229+
const current = paragraphs[i];
230+
const next = paragraphs[i + 1];
231+
232+
if ($(current).next()[0] !== next) continue;
233+
234+
const text = $(current).text().trim();
235+
if (!text) continue;
236+
237+
// If it ends like a complete sentence, keep separate
238+
if (/[.!?]["'"']?$/.test(text)) continue;
239+
240+
$(current).append(' ');
241+
$(current).append($(next).contents());
242+
$(next).remove();
243+
paragraphs.splice(i + 1, 1);
244+
i--;
245+
}
246+
247+
return $.html();
248+
}
249+
250+
// ── searchNovels ────────────────────────────────────────────────────────────
251+
252+
async searchNovels(
253+
searchTerm: string,
254+
pageNo: number,
255+
): Promise<Plugin.NovelItem[]> {
256+
// Guard: empty term would return the default category list, not search results.
257+
if (!searchTerm.trim()) return [];
258+
259+
// WP REST categories?search= searches novel names directly, returns covers.
260+
const cats = await fetchCategories(this.rest, {
261+
search: searchTerm.trim(),
262+
per_page: PAGE_SIZE,
263+
page: pageNo,
264+
hide_empty: 1,
265+
_fields: 'id,name,slug,count,description,link',
266+
});
267+
268+
return cats.map(catToNovel);
269+
}
270+
271+
// ── resolveUrl ──────────────────────────────────────────────────────────────
272+
273+
resolveUrl = (path: string): string => this.site + path;
274+
}
275+
276+
export default new Novel7sPlugin();
7.42 KB
Loading

0 commit comments

Comments
 (0)