feat(english): add novel7s extension (#2430)
This commit is contained in:
@@ -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 <img src="cover-url" />
|
||||
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<string, string | number>,
|
||||
): Promise<WPCategory[]> {
|
||||
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<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
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<Plugin.SourceNovel> {
|
||||
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<WPPost[]>,
|
||||
),
|
||||
),
|
||||
);
|
||||
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<string> {
|
||||
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 <p> that only contains <strong> (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<Plugin.NovelItem[]> {
|
||||
// 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();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
Reference in New Issue
Block a user