Files
lnreader-plugins/plugins/english/fucknovelpia.ts
T
2026-07-18 22:33:42 +05:00

739 lines
27 KiB
TypeScript

import { CheerioAPI, load as parseHTML } from 'cheerio';
import { Parser } from 'htmlparser2';
import { Plugin } from '@/types/plugin';
import { defaultCover } from '@libs/defaultCover';
import { NovelStatus } from '@libs/novelStatus';
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.1.1';
// 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('.card-book a').each((i, el) => {
const $el = cheerio(el);
const href = $el.attr('href');
if (!href) return;
const path = href.startsWith('/') ? href.slice(1) : href;
if (seen.has(path)) return;
const img = $el.find('img').attr('src');
const title =
$el.find('img').attr('alt') || $el.find('.title').text().trim();
seen.add(path);
novels.push({
path,
name: title,
cover: img || defaultCover,
});
});
return novels;
}
async parseLatestUpdates() {
const link = this.site + 'updates.php';
const response = await fetchApi(link);
const html = await response.text();
const novels: Plugin.NovelItem[] = [];
let tempNovel: Partial<Plugin.NovelItem> = {};
const seen = new Set<string>();
const stateStack: ParsingState[] = [ParsingState.Idle];
const currentState = () => stateStack[stateStack.length - 1];
const pushState = (state: ParsingState) => stateStack.push(state);
const popState = () =>
stateStack.length > 1 ? stateStack.pop() : currentState();
const parser = new Parser({
onopentag: (name, attribs) => {
const state = currentState();
if (attribs.class === 'updates-list') {
pushState(ParsingState.NovelList);
}
if (state === ParsingState.Idle) return;
switch (name) {
case 'a':
if (seen.has(attribs.href)) return;
seen.add(attribs.href);
pushState(ParsingState.Novel);
tempNovel.path = attribs.href.substring(1);
break;
case 'img':
tempNovel.cover = attribs.src;
tempNovel.name = attribs.alt;
break;
case 'div':
switch (attribs.class) {
case 'copy':
if (tempNovel.name && tempNovel.path) {
novels.push({ ...tempNovel } as Plugin.NovelItem);
popState();
}
tempNovel = {};
break;
case 'install-banner':
popState();
break;
}
break;
}
},
});
parser.write(html);
parser.end();
return novels;
}
async popularNovels(
page: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
if (showLatestNovels) {
return this.parseLatestUpdates();
}
const params = new URLSearchParams();
params.set('q', '');
params.set('sort', filters.sort.value);
// Search metadata filters (appears all the time)
for (const key of [
'author',
'uploader',
'translator_group',
'country',
'year_from',
'year_to',
] as const) {
params.set(key, filters[key].value);
}
params.set('status', filters.status.value);
params.set('language', filters.lang.value);
params.set('read_only', filters.read_only.value);
if (filters.has_images?.value) {
params.set('has_images', '1');
}
// Genre filters
params.set('genre_mode', filters.genres_include_operator.value);
for (const value of filters.genres?.value?.include ?? []) {
params.append('genres_include[]', value);
}
for (const value of filters.genres?.value?.exclude ?? []) {
params.append('genres_exclude[]', value);
}
// Tag filters
params.set('tag_mode', filters.tags_include_operator.value);
for (const value of filters.tags?.value?.include ?? []) {
params.append('tags_include[]', value);
}
for (const value of filters.tags?.value?.exclude ?? []) {
params.append('tags_exclude[]', value);
}
if (page > 1) {
params.set('page', String(page));
}
const link = this.site + 'search.php?' + params.toString();
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 novelInfo = loadedCheerio(
'script[type="application/ld+json"]',
).text();
let parsed: {
name?: string;
image?: string;
description?: string;
author?: { name?: string } | { name?: string }[];
genre?: string[];
} = {};
try {
parsed = JSON.parse(novelInfo);
} catch {
// JSON-LD missing or malformed — fields will fall back to defaults
}
const name = parsed.name || loadedCheerio('h1').text().trim();
const cover = parsed.image || defaultCover;
const summary =
parsed.description || loadedCheerio('.hero-summary').text().trim();
let author = [parsed.author]
.flat()
.map(a => a?.name)
.filter(Boolean)
.join(', ');
if (!author) {
author = loadedCheerio('.info-list li')
.first()
.text()
.replace(/^Author:\s*/i, '')
.trim();
}
let genres = [parsed.genre].flat().filter(Boolean).join(',');
if (!genres) {
genres = loadedCheerio('.genre-pill')
.map((_, el) => loadedCheerio(el).text().trim())
.get()
.filter(Boolean)
.join(',');
}
const novel: Plugin.SourceNovel = {
path: novelPath,
name: name || 'Untitled',
cover,
summary,
author,
genres,
chapters: [],
};
const rawStatus = loadedCheerio('.status-badge').text().trim();
const statusMap: Record<string, string | undefined> = {
ongoing: NovelStatus.Ongoing,
completed: NovelStatus.Completed,
hiatus: NovelStatus.OnHiatus,
dropped: NovelStatus.Cancelled,
};
novel.status = statusMap[rawStatus.toLowerCase()] || NovelStatus.Unknown;
const chapters: Plugin.ChapterItem[] = [];
loadedCheerio('#chapter-list li').each((i, el) => {
const $el = loadedCheerio(el);
const href = ($el.find('a').attr('href') || '').trim();
const path = href.startsWith('/') ? href.slice(1) : href;
const name = $el.find('.chapter-item-main').text().trim();
if (!path || !name) return;
chapters.push({
name: name + ($el.find('.chapter-item-flag').length ? ' [IMG]' : ''),
path,
chapterNumber: Number($el.attr('data-ch')),
});
});
// 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[]> {
const params = new URLSearchParams();
params.set('q', searchTerm);
params.set('author', '');
params.set('uploader', '');
params.set('translator_group', '');
params.set('country', '');
params.set('year_from', '');
params.set('year_to', '');
params.set('status', '');
params.set('language', '');
params.set('read_only', 'any');
params.set('sort', 'newest');
params.set('tag_mode', 'AND');
params.set('genre_mode', 'AND');
if (page > 1) {
params.set('page', String(page));
}
const link = this.site + 'search.php?' + params.toString();
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,
},
status: {
label: 'Status',
value: '',
options: [
{ label: 'Any', value: '' },
{ label: 'Ongoing', value: 'ongoing' },
{ label: 'Completed', value: 'completed' },
{ label: 'Hiatus', value: 'hiatus' },
{ label: 'Dropped', value: 'dropped' },
],
type: FilterTypes.Picker,
},
lang: {
label: 'Language',
value: '',
options: [
{ label: 'Any', value: '' },
{ label: 'EN', value: 'en' },
{ label: 'ES', value: 'es' },
{ label: 'KO', value: 'ko' },
{ label: 'JA', value: 'ja' },
{ label: 'ZH', value: 'zh' },
],
type: FilterTypes.Picker,
},
has_images: {
label: 'Image Chapters',
value: false,
type: FilterTypes.Switch,
},
read_only: {
label: 'Read Mode',
value: 'and',
options: [
{ label: 'Any', value: 'any' },
{ label: 'Read Only', value: 'yes' },
{ label: 'Downloadable', value: 'no' },
],
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: {
label: 'Tags',
value: {
include: [],
exclude: [],
},
options: [
{ label: '583', value: '583' },
{ label: '600', value: '600' },
{ label: '606', value: '606' },
{ label: '621', value: '621' },
{ label: '644', value: '644' },
{ label: 'Academy Setting', value: 'academy setting' },
{ label: 'Adventurer Guild', value: 'adventurer guild' },
{ label: 'Age Gap', value: 'age gap' },
{ label: 'AI', value: 'ai' },
{ label: 'Alchemy', value: 'alchemy' },
{ label: 'Aliens', value: 'aliens' },
{ label: 'Alternate History', value: 'alternate history' },
{ label: 'Androids', value: 'androids' },
{ label: 'Angels', value: 'angels' },
{ label: 'Anti-Hero', value: 'anti-hero' },
{ label: 'Apocalypse System', value: 'apocalypse system' },
{ label: 'Army Building', value: 'army building' },
{ label: 'Arranged Marriage', value: 'arranged marriage' },
{ label: 'Artifact User', value: 'artifact user' },
{ label: 'Assassin', value: 'assassin' },
{ label: 'Awakening', value: 'awakening' },
{ label: 'Bad Girl', value: 'bad girl' },
{ label: 'Battle Heavy', value: 'battle heavy' },
{ label: 'Beast Taming', value: 'beast taming' },
{ label: 'Beastfolk', value: 'beastfolk' },
{ label: 'Berserker', value: 'berserker' },
{ label: 'Betrayal', value: 'betrayal' },
{ label: 'Bisexual Protagonist', value: 'bisexual protagonist' },
{ label: 'Blackmail', value: 'blackmail' },
{ label: 'Blood Magic', value: 'blood magic' },
{ label: 'Body Horror', value: 'body horror' },
{ label: 'Brainwashing', value: 'brainwashing' },
{ label: 'Breakup', value: 'breakup' },
{ label: 'Broken Protagonist', value: 'broken protagonist' },
{ label: 'Bureaucracy', value: 'bureaucracy' },
{ label: 'Cat Girl', value: 'cat girl' },
{ label: 'Cheat Ability', value: 'cheat ability' },
{ label: 'Childhood Friends', value: 'childhood friends' },
{ label: 'Chosen One', value: 'chosen one' },
{ label: 'Church', value: 'church' },
{ label: 'City Building', value: 'city building' },
{ label: 'Class System', value: 'class system' },
{ label: 'Cold Protagonist', value: 'cold protagonist' },
{ label: 'Comedy', value: 'comedy' },
{ label: 'Conspiracy', value: 'conspiracy' },
{ label: 'Contract Marriage', value: 'contract marriage' },
{ label: 'Cooking', value: 'cooking' },
{ label: 'Corporate War', value: 'corporate war' },
{ label: 'Corruption', value: 'corruption' },
{ label: 'Courtroom', value: 'courtroom' },
{ label: 'Crafting', value: 'crafting' },
{ label: 'Crime', value: 'crime' },
{ label: 'Criminal Organization', value: 'criminal organization' },
{ label: 'Cult', value: 'cult' },
{ label: 'Cultivation', value: 'cultivation' },
{ label: 'Cursed Power', value: 'cursed power' },
{ label: 'Cyberpunk', value: 'cyberpunk' },
{ label: 'Daily Life', value: 'daily life' },
{ label: 'Dark Fantasy', value: 'dark fantasy' },
{ label: 'Dark Magic', value: 'dark magic' },
{ label: 'Demon Lord', value: 'demon lord' },
{ label: 'Demon Powers', value: 'demon powers' },
{ label: 'Demon Protagonist', value: 'demon protagonist' },
{ label: 'Demons', value: 'demons' },
{ label: 'Depression', value: 'depression' },
{ label: 'Detective', value: 'detective' },
{ label: 'Detective Setting', value: 'detective setting' },
{ label: 'Diplomacy', value: 'diplomacy' },
{ label: 'Divine Powers', value: 'divine powers' },
{ label: 'Domestic Life', value: 'domestic life' },
{ label: 'Dragon Protagonist', value: 'dragon protagonist' },
{ label: 'Dragonkin', value: 'dragonkin' },
{ label: 'Dungeon', value: 'dungeon' },
{ label: 'Dungeon World', value: 'dungeon world' },
{ label: 'Dwarves', value: 'dwarves' },
{ label: 'Dystopia', value: 'dystopia' },
{ label: 'Economy', value: 'economy' },
{ label: 'Elemental Magic', value: 'elemental magic' },
{ label: 'Elves', value: 'elves' },
{ label: 'Empire Building', value: 'empire building' },
{ label: 'Enemies To Lovers', value: 'enemies to lovers' },
{ label: 'Erotic', value: 'erotic' },
{ label: 'Experiments', value: 'experiments' },
{ label: 'Explicit Sex', value: 'explicit sex' },
{ label: 'Fake Dating', value: 'fake dating' },
{ label: 'Family Drama', value: 'family drama' },
{ label: 'Farming', value: 'farming' },
{ label: 'Female Protagonist', value: 'female protagonist' },
{ label: 'Forbidden Love', value: 'forbidden love' },
{ label: 'Forbidden Magic', value: 'forbidden magic' },
{ label: 'Forensics', value: 'forensics' },
{ label: 'Found Family', value: 'found family' },
{ label: 'Fox Girl', value: 'fox girl' },
{ label: 'Friends To Lovers', value: 'friends to lovers' },
{ label: 'Game World', value: 'game world' },
{ label: 'Game-Like World', value: 'game-like world' },
{ label: 'Gangs', value: 'gangs' },
{ label: 'Gaslighting', value: 'gaslighting' },
{ label: 'Gender Bender', value: 'gender bender' },
{ label: 'Genetic Engineering', value: 'genetic engineering' },
{ label: 'Genius Protagonist', value: 'genius protagonist' },
{ label: 'Girl', value: 'girl' },
{ label: 'Goblins', value: 'goblins' },
{ label: 'Gods', value: 'gods' },
{ label: 'Gore', value: 'gore' },
{ label: 'Grief', value: 'grief' },
{ label: 'Hacking', value: 'hacking' },
{ label: 'Harem', value: 'harem' },
{ label: 'Healer', value: 'healer' },
{ label: 'Healing Story', value: 'healing story' },
{ label: 'Heist', value: 'heist' },
{ label: 'Hero', value: 'hero' },
{ label: 'Historical Setting', value: 'historical setting' },
{ label: 'Holy Magic', value: 'holy magic' },
{ label: 'Horror Elements', value: 'horror elements' },
{ label: 'Hypnosis', value: 'hypnosis' },
{ label: 'Imperial Court', value: 'imperial court' },
{ label: 'Investigation', value: 'investigation' },
{ label: 'Isekai', value: 'isekai' },
{ label: 'Kemonomimi', value: 'kemonomimi' },
{ label: 'Kidnapping', value: 'kidnapping' },
{ label: 'Kind Protagonist', value: 'kind protagonist' },
{ label: 'Kingdom Building', value: 'kingdom building' },
{ label: 'Kingdom Setting', value: 'kingdom setting' },
{ label: 'Leadership', value: 'leadership' },
{ label: 'Leveling', value: 'leveling' },
{ label: 'LitRPG', value: 'litrpg' },
{ label: 'Love Triangle', value: 'love triangle' },
{ label: 'Madness', value: 'madness' },
{ label: 'Mafia', value: 'mafia' },
{ label: 'Mage Protagonist', value: 'mage protagonist' },
{ label: 'Magic', value: 'magic' },
{ label: 'Male Protagonist', value: 'male protagonist' },
{ label: 'Management', value: 'management' },
{ label: 'Manipulation', value: 'manipulation' },
{ label: 'Marriage', value: 'marriage' },
{ label: 'Martial Arts', value: 'martial arts' },
{ label: 'Mastermind Protagonist', value: 'mastermind protagonist' },
{ label: 'Mecha', value: 'mecha' },
{ label: 'Medieval Fantasy', value: 'medieval fantasy' },
{ label: 'Mental Breakdown', value: 'mental breakdown' },
{ label: 'Mercenaries', value: 'mercenaries' },
{ label: 'Merchant Life', value: 'merchant life' },
{ label: 'Military', value: 'military' },
{ label: 'Military Setting', value: 'military setting' },
{ label: 'Mind Break', value: 'mind break' },
{ label: 'Modern World', value: 'modern world' },
{ label: 'Monster Girls', value: 'monster girls' },
{ label: 'Monster Protagonist', value: 'monster protagonist' },
{ label: 'Moral Dilemmas', value: 'moral dilemmas' },
{
label: 'Morally Gray Protagonist',
value: 'morally gray protagonist',
},
{ label: 'Multiple Protagonists', value: 'multiple protagonists' },
{ label: 'Mystery', value: 'mystery' },
{ label: 'Naive Protagonist', value: 'naive protagonist' },
{ label: 'Necromancy', value: 'necromancy' },
{ label: 'Negotiation', value: 'negotiation' },
{ label: 'Nobles', value: 'nobles' },
{ label: 'Non-Consensual', value: 'non-consensual' },
{ label: 'Non-Human Protagonist', value: 'non-human protagonist' },
{ label: 'NTR', value: 'ntr' },
{ label: 'Obsession', value: 'obsession' },
{ label: 'Obsessive Love', value: 'obsessive love' },
{ label: 'Orcs', value: 'orcs' },
{ label: 'Orphan Protagonist', value: 'orphan protagonist' },
{ label: 'Overpowered Protagonist', value: 'overpowered protagonist' },
{ label: 'Parallel World', value: 'parallel world' },
{ label: 'Parody', value: 'parody' },
{ label: 'Police', value: 'police' },
{ label: 'Political Intrigue', value: 'political intrigue' },
{ label: 'Political Marriage', value: 'political marriage' },
{ label: 'Politics', value: 'politics' },
{ label: 'Polyamory', value: 'polyamory' },
{ label: 'Possessive Love', value: 'possessive love' },
{ label: 'Post-Apocalyptic', value: 'post-apocalyptic' },
{ label: 'Prince Protagonist', value: 'prince protagonist' },
{ label: 'Princess Protagonist', value: 'princess protagonist' },
{ label: 'Progression Fantasy', value: 'progression fantasy' },
{ label: 'Prostitution', value: 'prostitution' },
{ label: 'Prostitution Arc', value: 'prostitution arc' },
{ label: 'Psychological', value: 'psychological' },
{ label: 'Rape', value: 'rape' },
{ label: 'Rebels', value: 'rebels' },
{ label: 'Reconciliation', value: 'reconciliation' },
{ label: 'Redemption', value: 'redemption' },
{ label: 'Regression', value: 'regression' },
{ label: 'Reincarnated Villainess', value: 'reincarnated villainess' },
{ label: 'Reincarnation', value: 'reincarnation' },
{ label: 'Revenge', value: 'revenge' },
{ label: 'Revenge Driven', value: 'revenge driven' },
{ label: 'Reverse Harem', value: 'reverse harem' },
{ label: 'Romance', value: 'romance' },
{ label: 'Royalty', value: 'royalty' },
{ label: 'Ruthless Protagonist', value: 'ruthless protagonist' },
{ label: 'Saintess Protagonist', value: 'saintess protagonist' },
{ label: 'Satire', value: 'satire' },
{ label: 'Scheming', value: 'scheming' },
{ label: 'School Setting', value: 'school setting' },
{ label: 'Sci-Fi', value: 'sci-fi' },
{ label: 'Sci-Fi Setting', value: 'sci-fi setting' },
{ label: 'Second Chance', value: 'second chance' },
{ label: 'Secret Organization', value: 'secret organization' },
{ label: 'Serial Killer', value: 'serial killer' },
{ label: 'Sex Slavery', value: 'sex slavery' },
{ label: 'Sex Work', value: 'sex work' },
{ label: 'Shop Owner', value: 'shop owner' },
{ label: 'Simulation', value: 'simulation' },
{ label: 'Skill System', value: 'skill system' },
{ label: 'Slave Heroine', value: 'slave heroine' },
{ label: 'Slice of Life', value: 'slice of life' },
{ label: 'Slime', value: 'slime' },
{ label: 'Slow Burn', value: 'slow burn' },
{ label: 'Smut', value: 'smut' },
{ label: 'Soulmate', value: 'soulmate' },
{ label: 'Space Opera', value: 'space opera' },
{ label: 'Space Travel', value: 'space travel' },
{ label: 'Spider', value: 'spider' },
{ label: 'Spies', value: 'spies' },
{ label: 'Spirits', value: 'spirits' },
{ label: 'Stats Window', value: 'stats window' },
{ label: 'Steampunk', value: 'steampunk' },
{ label: 'Strategy', value: 'strategy' },
{ label: 'Summoner', value: 'summoner' },
{ label: 'Summoning', value: 'summoning' },
{ label: 'Super Soldiers', value: 'super soldiers' },
{ label: 'Superhero Setting', value: 'superhero setting' },
{ label: 'Survival Combat', value: 'survival combat' },
{ label: 'Suspense', value: 'suspense' },
{ label: 'Swordsmanship', value: 'swordsmanship' },
{ label: 'System', value: 'system' },
{ label: 'Tactics', value: 'tactics' },
{ label: 'Talent Growth', value: 'talent growth' },
{ label: 'Thriller', value: 'thriller' },
{ label: 'Time Loop', value: 'time loop' },
{ label: 'Time Travel', value: 'time travel' },
{ label: 'Torture', value: 'torture' },
{ label: 'Tower', value: 'tower' },
{ label: 'Toxic Relationship', value: 'toxic relationship' },
{ label: 'Trade', value: 'trade' },
{ label: 'Tragedy', value: 'tragedy' },
{ label: 'Tragic Protagonist', value: 'tragic protagonist' },
{ label: 'Training Arc', value: 'training arc' },
{ label: 'Transmigration', value: 'transmigration' },
{ label: 'Trauma', value: 'trauma' },
{ label: 'TS', value: 'ts' },
{ label: 'Undead', value: 'undead' },
{ label: 'Undead Protagonist', value: 'undead protagonist' },
{ label: 'Underworld', value: 'underworld' },
{ label: 'Underworld Setting', value: 'underworld setting' },
{ label: 'Urban Fantasy', value: 'urban fantasy' },
{ label: 'Vampire', value: 'vampire' },
{ label: 'Villain Protagonist', value: 'villain protagonist' },
{ label: 'Violence', value: 'violence' },
{ label: 'Virtual Reality', value: 'virtual reality' },
{ label: 'Virtual World', value: 'virtual world' },
{ label: 'War Arc', value: 'war arc' },
{ label: 'War Strategy', value: 'war strategy' },
{ label: 'Warrior Protagonist', value: 'warrior protagonist' },
{ label: 'Weak To Strong', value: 'weak to strong' },
{ label: 'Werewolf', value: 'werewolf' },
{ label: 'Wholesome', value: 'wholesome' },
{ label: 'Wolf Girl', value: 'wolf girl' },
{ label: 'Wuxia', value: 'wuxia' },
{ label: 'Xianxia', value: 'xianxia' },
{ label: 'Yaoi', value: 'yaoi' },
{ label: 'Yuri', value: 'yuri' },
],
type: FilterTypes.ExcludableCheckboxGroup,
},
author: {
label: 'Author',
value: '',
type: FilterTypes.TextInput,
},
uploader: {
label: 'Uploader',
value: '',
type: FilterTypes.TextInput,
},
translator_group: {
label: 'Translator Group',
value: '',
type: FilterTypes.TextInput,
},
country: {
label: 'Country',
value: '',
type: FilterTypes.TextInput,
},
year_from: {
label: 'Year From',
value: '',
type: FilterTypes.TextInput,
},
year_to: {
label: 'Year To',
value: '',
type: FilterTypes.TextInput,
},
} satisfies Filters;
}
export default new FuckNovelpia();
enum ParsingState {
Idle,
Novel,
NovelList,
}