feat: add fucknovelpia (#2294)

* feat: add fucknovelpia

* Update plugins/english/fucknovelpia.ts

Co-authored-by: K1ngfish3r <26593485+K1ngfish3r@users.noreply.github.com>

* Apply reviewer feedback: merge genres into ExcludableCheckboxGroup

* Fix: add missing encodeURIComponent

---------

Co-authored-by: notnsas <ramapanjinararendra@gmail.com>
Co-authored-by: K1ngfish3r <26593485+K1ngfish3r@users.noreply.github.com>
This commit is contained in:
isrealhamumu-star
2026-07-12 01:44:23 +07:00
committed by GitHub
parent cf37cb6464
commit c4545b0780
3 changed files with 502 additions and 198 deletions
+302
View File
@@ -0,0 +1,302 @@
import { CheerioAPI, load as parseHTML } from 'cheerio';
import { Plugin } from '@/types/plugin';
import { defaultCover } from '@libs/defaultCover';
import { fetchApi } from '@libs/fetch';
import { Filters, FilterTypes } from '@libs/filterInputs';
class FuckNovelpia implements Plugin.PluginBase {
id = 'FuckNovelpia';
name = 'FuckNovelpia';
icon = 'src/en/fucknovelpia/icon.png';
site = 'https://fucknovelpia.com/';
version = '1.0.0';
// Returns false once the site has silently clamped us past the real last page.
hasRequestedPage(cheerio: CheerioAPI, requestedPage: number): boolean {
if (requestedPage <= 1) return true;
const activeText = cheerio('div.pagination a.active').first().text().trim();
const activePage = parseInt(activeText, 10);
// No pagination at all, or active page doesn't match what we asked for
// -> the site redirected us (usually back to page 1). Stop here.
return !isNaN(activePage) && activePage === requestedPage;
}
parseNovelsList(cheerio: CheerioAPI): Plugin.NovelItem[] {
const novels: Plugin.NovelItem[] = [];
const seen = new Set<string>();
cheerio('div.grid a[href*="/novel/"]').each((i, el) => {
const $el = cheerio(el);
const href = $el.attr('href');
if (!href) return;
const path = href.replace(this.site, '').replace(/^\/+/, '');
if (!path.startsWith('novel/') || seen.has(path)) return;
const card = $el.closest('div, li, article');
const img =
$el.find('img').attr('src') ||
card.find('img').first().attr('src') ||
undefined;
const title =
card.find('h3, h2, .title, strong').first().text().trim() ||
$el.attr('title')?.trim() ||
$el.text().trim().split('\n')[0].trim();
if (!title) return;
seen.add(path);
novels.push({
name: title,
path,
cover: img || defaultCover,
});
});
return novels;
}
async popularNovels(
page: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
let link;
if (showLatestNovels) {
link = this.site + 'search.php?q=&sort=latest';
} else {
link = this.site + 'search.php' + '?q=&sort=popular';
link += `&sort=${encodeURIComponent(filters?.sort?.value ?? 'newest')}`;
}
if (page > 1) {
link += '&page=' + page;
}
const genreIncludeParams = (filters?.genres?.value?.include ?? [])
.map(value => `&genres_include%5B%5D=${value}`)
.join('');
const genreExcludeParams = (filters?.genres?.value?.exclude ?? [])
.map(value => `&genres_exclude%5B%5D=${value}`)
.join('');
link +=
'&genre_mode=' +
filters.genres_include_operator.value +
genreIncludeParams +
genreExcludeParams;
const tagIncludeParams = (filters?.tags_include?.value ?? '')
.split(/[,\s]+/)
.map(tag => tag.trim())
.filter(Boolean)
.map(tag => `&tags_include%5B%5D=${encodeURIComponent(tag)}`)
.join('');
const tagExcludeParams = (filters?.tags_exclude?.value ?? '')
.split(/[,\s]+/)
.map(tag => tag.trim())
.filter(Boolean)
.map(tag => `&tags_exclude%5B%5D=${encodeURIComponent(tag)}`)
.join('');
link +=
'&tag_mode=' +
filters.tags_include_operator.value +
tagIncludeParams +
tagExcludeParams;
const result = await fetchApi(link);
const body = await result.text();
const loadedCheerio = parseHTML(body);
if (!this.hasRequestedPage(loadedCheerio, page)) return [];
return this.parseNovelsList(loadedCheerio);
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const result = await fetchApi(this.site + novelPath);
const body = await result.text();
const loadedCheerio = parseHTML(body);
const infoMap: Record<string, string> = {};
loadedCheerio('li, dd, p').each((i, el) => {
const text = loadedCheerio(el).text().trim();
const match = text.match(/^([A-Za-z][A-Za-z ]{2,20}):\s*(.+)$/);
if (match && !infoMap[match[1].trim()]) {
infoMap[match[1].trim()] = match[2].trim();
}
});
const ogTitle =
loadedCheerio('meta[property="og:title"]').attr('content') || '';
const name =
loadedCheerio('h1').first().text().trim() || ogTitle.split('·')[0].trim();
const cover =
loadedCheerio('meta[property="og:image"]').attr('content') ||
defaultCover;
const summary =
loadedCheerio('meta[property="og:description"]').attr('content') || '';
const genres = loadedCheerio(
'a[href*="tags%5B"], a[href*="tags["], a[href*="genres%5B"], a[href*="genres["]',
)
.map((i, el) => loadedCheerio(el).text().trim())
.toArray()
.filter(Boolean)
.join(', ');
const novel: Plugin.SourceNovel = {
path: novelPath,
name: name || 'Untitled',
cover,
summary,
author: infoMap['Author'] || '',
genres,
chapters: [],
};
const rawStatus = infoMap['Status'] || '';
if (/ongoing/i.test(rawStatus)) {
novel.status = 'Ongoing';
} else if (/completed/i.test(rawStatus)) {
novel.status = 'Completed';
} else if (rawStatus) {
novel.status = rawStatus;
}
const chapters: Plugin.ChapterItem[] = [];
loadedCheerio(
'a[href*="chapter.php?hash="], a[href*="chapter.php?ch="]',
).each((i, el) => {
const $el = loadedCheerio(el);
const path = ($el.attr('href') || '')
.trim()
.replace(this.site, '')
.replace(/^\/+/, '');
const name = $el.text().trim();
if (!path || !name) return;
chapters.push({ name, path });
});
// Chapters on the novel page are already listed oldest -> newest.
novel.chapters = chapters;
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const result = await fetchApi(this.site + chapterPath);
const body = await result.text();
const $ = parseHTML(body);
const chapter = $('.reader').first();
if (!chapter.length) {
return '';
}
// Remove things that aren't part of the chapter
chapter.find('.reader-nav').remove();
chapter.find('script').remove();
chapter.find('style').remove();
return chapter.html()?.replace(/&nbsp;/g, ' ') ?? '';
}
async searchNovels(
searchTerm: string,
page: number,
): Promise<Plugin.NovelItem[]> {
let link = this.site + '?q=' + encodeURIComponent(searchTerm);
if (page > 1) {
link += '&page=' + page;
}
const result = await fetchApi(link);
const body = await result.text();
const loadedCheerio = parseHTML(body);
if (!this.hasRequestedPage(loadedCheerio, page)) return [];
return this.parseNovelsList(loadedCheerio);
}
filters = {
sort: {
label: 'Sort',
value: 'newest',
options: [
{ label: 'Newest', value: 'newest' },
{ label: 'Popular', value: 'popular' },
{ label: 'Oldest', value: 'oldest' },
{ label: 'Title A-Z', value: 'title' },
{ label: 'Year (Descending)', value: 'year_desc' },
{ label: 'Year (Ascending)', value: 'year_asc' },
],
type: FilterTypes.Picker,
},
genres_include_operator: {
label: 'Include Genres',
value: 'and',
options: [
{ label: 'AND', value: 'and' },
{ label: 'OR', value: 'or' },
],
type: FilterTypes.Picker,
},
genres: {
label: 'Genres',
value: {
include: [],
exclude: [],
},
options: [
{ label: 'Academy', value: '1' },
{ label: 'Action', value: '2' },
{ label: 'Adventure', value: '3' },
{ label: 'Fantasy', value: '4' },
{ label: 'Horror', value: '5' },
{ label: 'Mystery', value: '6' },
{ label: 'Romance', value: '7' },
{ label: 'School', value: '8' },
{ label: 'Martial', value: '9' },
{ label: 'Smut', value: '10' },
{ label: 'Adult', value: '11' },
{ label: 'Harem', value: '12' },
{ label: 'Historical', value: '13' },
{ label: 'Sci-Fi', value: '14' },
{ label: 'Slice of Life', value: '15' },
{ label: 'Sports', value: '16' },
{ label: 'Uncategorized', value: '17' },
],
type: FilterTypes.ExcludableCheckboxGroup,
},
tags_include_operator: {
label: 'Include Tags',
value: 'and',
options: [
{ label: 'AND', value: 'and' },
{ label: 'OR', value: 'or' },
],
type: FilterTypes.Picker,
},
tags_include: {
label: 'Tags Include',
value: '',
type: FilterTypes.TextInput,
},
tags_exclude: {
label: 'Tags Exclude',
value: '',
type: FilterTypes.TextInput,
},
} satisfies Filters;
}
export default new FuckNovelpia();