diff --git a/public/static/src/en/novelfull/icon.png b/public/static/multisrc/readnovelfull/novelfull/icon.png similarity index 100% rename from public/static/src/en/novelfull/icon.png rename to public/static/multisrc/readnovelfull/novelfull/icon.png diff --git a/scripts/multisrc/readnovelfull/README.md b/scripts/multisrc/readnovelfull/README.md index 3c8c8f4..ab799a9 100644 --- a/scripts/multisrc/readnovelfull/README.md +++ b/scripts/multisrc/readnovelfull/README.md @@ -4,7 +4,7 @@ ### This generator is brought over from the madara generator one folder away -it should(!) work for all sites that uses this theme +it should(!) work for all sites that uses this theme. If not, edit. ## Add a new source @@ -16,12 +16,23 @@ To add a new source you need to add it to sources.json: - sourceName: the name of the source (you can use the value of "name" in "https://site.com/wp-json/" if it exists) - sourceSite: the site url - options: the options of the source - - lang: the language of the source (default: "English") (check that the language + - lang?: the language of the source (default: "English") (check that the language exists in the languages (check folder names in "plugins/")) - - versionIncrements: needs to be updated everytime the site url is updated - - popularPage: the href value using this CSS selector `a[title="Latest Release"]` - - ajax-chapter-list: if chapters are obtained from an ajax endpoint - - pageParam: if a site uses something other than page for accessing pages + - versionIncrements?: needs to be updated everytime the site url is updated + - latestPage: the href value using this CSS selector `a[title="Latest Release"]` + - searchPage: path for search + - chapterListing?: if chapters are obtained from an ajax endpoint different from what is default. + - chapterParam?: same with chapterListing, but its the key for the form body. default: novelId + - pageParam?: if a site uses something other than "page" for accessing pages + + // All the following is for when if a page uses a main path for querying novels and just adds params to it to get different outputs (see urls in lightnovelplus) + - novelListing?: Main path for browsing novels + - typeParam?: Field for entering the type of novelListing (eg: latest, hot, completed, etc), default: type + - genreParam?: Field for entering type of novelListing dedicated for genres (since param websites dont use the path change), default: category_novel + - genreKey?: Field for entering key representing the genre within the genreParam, default: id (eg: ?type=category_novel&id={value}) + - langParam?: Field for entering the key representing language(?), do not understand reason for this param honestly, but recreate normal website behaviour as much as possible, default: none. + - urlLangCode?: the value for langParam, default: none. eg: 'en' + ### filters diff --git a/scripts/multisrc/readnovelfull/filters/novelfull.json b/scripts/multisrc/readnovelfull/filters/novelfull.json new file mode 100644 index 0000000..deaa9ce --- /dev/null +++ b/scripts/multisrc/readnovelfull/filters/novelfull.json @@ -0,0 +1,174 @@ +{ + "filters": { + "type": { + "type": "Picker", + "label": "Novel Listing", + "value": "most-popular", + "options": [ + { + "label": "Hot Novel", + "value": "hot-novel" + }, + { + "label": "Completed Novel", + "value": "completed-novel" + }, + { + "label": "Most Popular", + "value": "most-popular" + } + ] + }, + "genres": { + "type": "Picker", + "label": "Genre", + "value": "", + "options": [ + { + "label": "Shounen", + "value": "genre/Shounen" + }, + { + "label": "Harem", + "value": "genre/Harem" + }, + { + "label": "Comedy", + "value": "genre/Comedy" + }, + { + "label": "Martial Arts", + "value": "genre/Martial+Arts" + }, + { + "label": "School Life", + "value": "genre/School+Life" + }, + { + "label": "Mystery", + "value": "genre/Mystery" + }, + { + "label": "Shoujo", + "value": "genre/Shoujo" + }, + { + "label": "Romance", + "value": "genre/Romance" + }, + { + "label": "Sci-fi", + "value": "genre/Sci-fi" + }, + { + "label": "Gender Bender", + "value": "genre/Gender+Bender" + }, + { + "label": "Mature", + "value": "genre/Mature" + }, + { + "label": "Fantasy", + "value": "genre/Fantasy" + }, + { + "label": "Horror", + "value": "genre/Horror" + }, + { + "label": "Drama", + "value": "genre/Drama" + }, + { + "label": "Tragedy", + "value": "genre/Tragedy" + }, + { + "label": "Supernatural", + "value": "genre/Supernatural" + }, + { + "label": "Ecchi", + "value": "genre/Ecchi" + }, + { + "label": "Xuanhuan", + "value": "genre/Xuanhuan" + }, + { + "label": "Adventure", + "value": "genre/Adventure" + }, + { + "label": "Action", + "value": "genre/Action" + }, + { + "label": "Psychological", + "value": "genre/Psychological" + }, + { + "label": "Xianxia", + "value": "genre/Xianxia" + }, + { + "label": "Wuxia", + "value": "genre/Wuxia" + }, + { + "label": "Historical", + "value": "genre/Historical" + }, + { + "label": "Slice of Life", + "value": "genre/Slice+of+Life" + }, + { + "label": "Seinen", + "value": "genre/Seinen" + }, + { + "label": "Lolicon", + "value": "genre/Lolicon" + }, + { + "label": "Adult", + "value": "genre/Adult" + }, + { + "label": "Josei", + "value": "genre/Josei" + }, + { + "label": "Sports", + "value": "genre/Sports" + }, + { + "label": "Smut", + "value": "genre/Smut" + }, + { + "label": "Mecha", + "value": "genre/Mecha" + }, + { + "label": "Yaoi", + "value": "genre/Yaoi" + }, + { + "label": "Shounen Ai", + "value": "genre/Shounen+Ai" + }, + { + "label": "History", + "value": "genre/History" + }, + { + "label": "Martial", + "value": "genre/Martial" + } + ] + } + } +} \ No newline at end of file diff --git a/scripts/multisrc/readnovelfull/get_filters.js b/scripts/multisrc/readnovelfull/get_filters.js index 6fe8293..69bcbd8 100644 --- a/scripts/multisrc/readnovelfull/get_filters.js +++ b/scripts/multisrc/readnovelfull/get_filters.js @@ -8,6 +8,26 @@ import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +function extractValueFromHref(href, baseUrl, name, isGenre = false) { + const fullUrl = new URL(decodeURI(href), baseUrl); + const params = fullUrl.searchParams; + + if (isGenre && params.get('type') === 'category_novel' && params.has('id')) { + return params.get('id'); + } + + const value = params.has('type') + ? params.get('type') + : fullUrl.pathname.substring(1) || ''; + + if (!value) { + console.warn( + `Skipping option for ${name}: Could not determine value from href "${href}"`, + ); + } + return value; +} + function getFilters(name, html, baseUrl) { const $ = cheerio.load(html); const filters = { @@ -40,30 +60,13 @@ function getFilters(name, html, baseUrl) { if (!href || !label || title === 'Latest Release') return; - try { - const fullUrl = new URL(decodeURI(href), baseUrl); - const params = fullUrl.searchParams; + const value = extractValueFromHref(href, baseUrl, name); - const value = params.has('type') - ? params.get('type') - : (fullUrl.pathname.startsWith('/') - ? fullUrl.pathname.substring(1) - : fullUrl.pathname) || ''; - - if (value) { - typeOptions.push({ label, value }); - if (title === 'Most Popular') { - defaultValue = value; - } - } else { - console.warn( - `Skipping type option "${label}" for ${name}: Could not determine value from href "${href}"`, - ); + if (value) { + typeOptions.push({ label, value }); + if (title === 'Most Popular') { + defaultValue = value; } - } catch (error) { - console.error( - `Error processing type URL (${href}) for ${name}: ${error.message}`, - ); } }); filters.filters.type.options = typeOptions; @@ -78,36 +81,18 @@ function getFilters(name, html, baseUrl) { if (!href || !label) return; - try { - const fullUrl = new URL(decodeURI(href), baseUrl); - const params = fullUrl.searchParams; + const value = extractValueFromHref(href, baseUrl, name, true); - const value = - params.get('type') === 'category_novel' && params.has('id') - ? params.get('id') - : (fullUrl.pathname.startsWith('/') - ? fullUrl.pathname.substring(1) - : fullUrl.pathname) || ''; - - if (value) { - genreOptions.push({ label, value }); - } else { - console.warn( - `Skipping genre option "${label}" for ${name}: Could not determine value from href "${href}"`, - ); - } - } catch (error) { - console.error( - `Error processing genre URL (${href}) for ${name}: ${error.message}`, - ); + if (value) { + genreOptions.push({ label, value }); } }); - filters.filters.genre.options = genreOptions; // Genre has no default + filters.filters.genres.options = genreOptions; // Genre has no default // --- Validation and Saving --- if ( filters.filters.type.options.length === 0 || - filters.filters.genre.options.length === 0 + filters.filters.genres.options.length === 0 ) { console.warn( `🚨Warning for ${name}: Type or Genre options might be incomplete or empty. (${path.join(__dirname, 'filters', name + '.json')})🚨`, @@ -144,73 +129,97 @@ async function getFiltersFromURL(name, url) { } } -async function askGetFilter() { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); +const EREASE_PREV_LINE = '\x1b[1A\r\x1b[2K'; +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); - const EREASE_PREV_LINE = '\x1b[1A\r\x1b[2K'; - rl.question( - 'Enter the id of the site (same one as in sources.json): ', - async name => { - rl.question( - EREASE_PREV_LINE + - "Do you want to get the filters from a URL or the html text? (if url dosen't work try html) (url/html): ", - async method => { - if (method.toLowerCase() === 'url') { - const sources = JSON.parse( - fs.readFileSync(path.join(__dirname, 'sources.json'), 'utf-8'), - ); - const source = sources.find(s => s.id === name); - if (source && source.sourceSite) { - console.log('Getting filters from', source.sourceSite); - try { - await getFiltersFromURL(name, source.sourceSite); - } catch (e) { +async function askGetFilter() { + try { + const sources = JSON.parse( + fs.readFileSync(path.join(__dirname, 'sources.json'), 'utf-8'), + ); + + rl.question( + 'Enter the id of the site (same one as in sources.json): ', + async name => { + const baseUrl = await getBaseUrl(name, sources); + + rl.question( + EREASE_PREV_LINE + + "Do you want to get the filters from a URL or the html text? (if url doesn't work try html) (url/html): ", + async method => { + if (method.toLowerCase() === 'url') { + if (baseUrl) { + console.log('Getting filters from', baseUrl); + try { + await getFiltersFromURL(name, baseUrl); + } catch (e) { + console.error('Error while getting filters from', baseUrl); + console.log(e.message || e); + } + } else { console.error( - 'Error while getting filters from', - source.sourceSite, + 'Cannot get filters from URL: Base URL is not available.', ); - console.log(e.message || e); } rl.close(); } else { rl.question( - EREASE_PREV_LINE + - 'Enter the URL (same one as in sources.json): ', - async url => { - rl.close(); + EREASE_PREV_LINE + 'Enter the absolute path to the HTML file: ', + async filePath => { try { - await getFiltersFromURL(name, url); + const html = fs.readFileSync(filePath, 'utf-8'); + if (baseUrl) { + console.log('Using base URL:', baseUrl); + } else { + console.error( + 'Cannot get filters from HTML: Base URL is not available.', + ); + } + getFilters(name, html, baseUrl); } catch (e) { - console.error('Error while getting filters from', url); - console.log(e.message || e); + console.error( + 'Error reading HTML file or getting filters:', + e.message || e, + ); + } finally { + rl.close(); } }, ); } - } else { - process.stdout.write( - EREASE_PREV_LINE + - `Enter the html text from the page at {sourceSite}/?s=&post_type=wp-manga (at the end press ENTER then press CTRL+C): `, - ); - let html = ''; - rl.on('SIGINT', () => { - console.log('Stopeed reading input, creating filters file'); - getFilters(name, html, url); - rl.close(); - }); - rl.on('line', line => { - html += line + '\n'; - }); - } - }, - ); - }, - ); + }, + ); + }, + ); + } catch (e) { + console.error('Error reading sources.json:', e.message || e); + rl.close(); + } } askGetFilter(); +async function getBaseUrl(name, sources) { + const source = sources.find(s => s.id === name); + if (source && source.sourceSite) { + console.log('Using base URL from sources.json:', source.sourceSite); + return source.sourceSite; + } else { + console.warn( + `Source with id "${name}" not found or missing sourceSite in sources.json.`, + ); + return new Promise(resolve => { + rl.question( + EREASE_PREV_LINE + 'Enter the base URL for the site: ', + manualBaseUrl => { + resolve(manualBaseUrl); + }, + ); + }); + } +} + export { getFiltersFromURL }; diff --git a/scripts/multisrc/readnovelfull/sources.json b/scripts/multisrc/readnovelfull/sources.json index 82d41f6..5340bf0 100644 --- a/scripts/multisrc/readnovelfull/sources.json +++ b/scripts/multisrc/readnovelfull/sources.json @@ -24,14 +24,13 @@ "sourceName": "LightNovelPlus", "options": { "novelListing": "book/bookclass.html", - "chapterListing": "get_chapter_list", "searchPage": "book/search.html", + "chapterListing": "get_chapter_list", + "chapterParam": "bookId", "latestPage": "last_release", - "genreParam": "category_novel", "pageParam": "page_num", "langParam": "language", - "urlLangCode": "en", - "ajaxChapterParam": "bookId" + "urlLangCode": "en" } }, { @@ -42,5 +41,15 @@ "latestPage": "sort/nov-love-daily-update", "searchPage": "search" } + }, + { + "id": "novelfull", + "sourceSite": "https://novelfull.com/", + "sourceName": "NovelFull", + "options": { + "latestPage": "latest-release-novel", + "searchPage": "search", + "chapterListing": "ajax-chapter-option" + } } ] diff --git a/scripts/multisrc/readnovelfull/template.ts b/scripts/multisrc/readnovelfull/template.ts index e819e1a..cf66eaa 100644 --- a/scripts/multisrc/readnovelfull/template.ts +++ b/scripts/multisrc/readnovelfull/template.ts @@ -9,15 +9,15 @@ type ReadNovelFullOptions = { versionIncrements?: number; latestPage: string; searchPage: string; - novelListing?: string; chapterListing?: string; - TypeParam?: string; + chapterParam?: string; + pageParam?: string; + novelListing?: string; + typeParam?: string; genreParam?: string; genreKey?: string; - pageParam?: string; langParam?: string; urlLangCode?: string; - ajaxChapterParam?: string; }; export type ReadNovelFullMetadata = { @@ -43,7 +43,7 @@ class ReadNovelFullPlugin implements Plugin.PluginBase { this.icon = `multisrc/readnovelfull/${metadata.id.toLowerCase()}/icon.png`; this.site = metadata.sourceSite; const versionIncrements = metadata.options?.versionIncrements || 0; - this.version = `2.0.${0 + versionIncrements}`; + this.version = `2.0.${1 + versionIncrements}`; this.options = metadata.options; this.filters = metadata.filters; } @@ -62,7 +62,7 @@ class ReadNovelFullPlugin implements Plugin.PluginBase { parseNovels($: CheerioAPI): Plugin.NovelItem[] { const baseUrl = this.site; - const novels = $('.list-novel .row, .list-truyen .row') + const novels = $('.archive .list-novel .row, .archive .list-truyen .row') .toArray() .map(el => { const $el = $(el); @@ -70,18 +70,18 @@ class ReadNovelFullPlugin implements Plugin.PluginBase { const novelName = $el.find('h3').text()?.trim(); const rawHref = $el.find('a').attr('href'); const rawCover = - $el.find('img').attr('data-src') || $el.find('img').attr('src'); + $el.find('img').attr('src') ?? + $el.find('img').attr('data-cfsrc') ?? + $el.find('img').attr('data-src'); if (!novelName || !rawHref) { return null; } - let novelPath: string; - let novelCover: string | undefined; - const novelUrlObject = new URL(rawHref, baseUrl); - novelPath = novelUrlObject.pathname.slice(1); + const novelPath = novelUrlObject.pathname.slice(1); + let novelCover: string | undefined; if (rawCover) { novelCover = new URL(rawCover, baseUrl).href; } @@ -107,12 +107,12 @@ class ReadNovelFullPlugin implements Plugin.PluginBase { if (this.options.novelListing) { // URL structure with parameters const params = new URLSearchParams(); - const typeParam = this.options.TypeParam || 'type'; // Default to 'type' if not specified + const typeParam = this.options.typeParam || 'type'; // Default to 'type' if not specified if (showLatestNovels) { params.append(typeParam, this.options.latestPage); } else if (filters.genres.value.length) { - params.append(typeParam, this.options.genreParam!); + params.append(typeParam, this.options.genreParam || 'category_novel'); // same below const genreKey = this.options.genreKey || 'id'; // Default to 'id' if not specified params.append(genreKey, filters.genres.value); } else { @@ -136,7 +136,7 @@ class ReadNovelFullPlugin implements Plugin.PluginBase { if (showLatestNovels) { basePage = this.options.latestPage; - } else if (filters?.genres?.value?.length) { + } else if (filters.genres.value.length) { basePage = filters.genres.value; } else { basePage = filters.type.value; @@ -159,7 +159,12 @@ class ReadNovelFullPlugin implements Plugin.PluginBase { novel.name = $('h3.title').first().text().trim(); const coverUrl = - $('.book img').attr('data-src') || $('.book img').attr('src'); + $('.book img').attr('src') ?? + $('.book img').attr('data-cfsrc') ?? + $('.book img').attr('data-src') ?? + $('.book noscript img').attr('src') ?? + $('meta[name="image"]').attr('content'); + if (coverUrl) { novel.cover = new URL(coverUrl, baseUrl).href; } @@ -206,13 +211,14 @@ class ReadNovelFullPlugin implements Plugin.PluginBase { const novelId = dataNovelId || (idMatch ? idMatch[0] : null); if (novelId) { - const ajaxParam = this.options.ajaxChapterParam || 'novelId'; const chapterListing = this.options.chapterListing || 'ajax/chapter-archive'; + const ajaxParam = this.options.chapterParam || 'novelId'; const params = new URLSearchParams({ [ajaxParam]: novelId.toString(), }); + const chaptersUrl = `${this.site}${chapterListing}?${params.toString()}`; $ = await this.getCheerio(chaptersUrl, false); @@ -241,8 +247,8 @@ class ReadNovelFullPlugin implements Plugin.PluginBase { async parseChapter(chapterPath: string): Promise { const $ = await this.getCheerio(this.site + chapterPath, false); - - return $('#chr-content, #chapter-content').html()!; + $('div.ads, div.unlock-buttons').remove(); + return $('#chr-content, #chapter-content').html()! || ''; } async searchNovels( diff --git a/src/plugins/english/novelfull.ts b/src/plugins/english/novelfull.ts deleted file mode 100644 index 576fa3e..0000000 --- a/src/plugins/english/novelfull.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { CheerioAPI, load as parseHTML } from 'cheerio'; -import { fetchApi } from '@libs/fetch'; -import { Plugin } from '@typings/plugin'; -import { Filters, FilterTypes } from '@libs/filterInputs'; - -class NovelFull implements Plugin.PluginBase { - id = 'novelfull'; - name = 'NovelFull'; - version = '1.0.3'; - icon = 'src/en/novelfull/icon.png'; - site = 'https://novelfull.com/'; - - parseNovels(loadedCheerio: CheerioAPI) { - const novels: Plugin.NovelItem[] = []; - - loadedCheerio('.col-truyen-main .list-truyen .row').each((idx, ele) => { - const novelName = loadedCheerio(ele).find('h3.truyen-title > a').text(); - - // Images from the site are now lazy loaded, so we need to check data-cfsrc as well - let novelCover = - loadedCheerio(ele).find('img').attr('src') ?? - loadedCheerio(ele).find('img').attr('data-cfsrc'); - - novelCover = novelCover ? this.site + novelCover.slice(1) : undefined; - - const novelUrl = loadedCheerio(ele) - .find('h3.truyen-title > a') - .attr('href') - ?.slice(1); - - if (!novelUrl) return; - - const novel = { - name: novelName, - cover: novelCover, - path: novelUrl, - }; - - novels.push(novel); - }); - - return novels; - } - - async popularNovels( - page: number, - { filters }: Plugin.PopularNovelsOptions, - ): Promise { - let link = this.site; - if (filters.genre.value !== '') link += `genre/${filters.genre.value}`; - else link += filters.sort.value; - - link += `?page=${page}`; - - const body = await fetchApi(link).then(r => r.text()); - - const loadedCheerio = parseHTML(body); - return this.parseNovels(loadedCheerio); - } - - async parseNovel(novelPath: string): Promise { - const result = await fetchApi(this.site + novelPath); - const body = await result.text(); - - let loadedCheerio = parseHTML(body); - - // Images from the site are now lazy loaded, so we need to check data-cfsrc as well (and fallback to other locations) - let cover = - loadedCheerio('div.book > img').attr('src') ?? - loadedCheerio('div.book > img').attr('data-cfsrc') ?? - loadedCheerio('div.book > noscript > img').attr('src') ?? - loadedCheerio('meta[name="image"]').attr('content'); - - cover = cover ? this.site + cover.slice(1) : undefined; - - const novel: Plugin.SourceNovel = { - path: novelPath, - name: loadedCheerio('div.book > img').attr('alt') || 'Untitled', - cover: cover, - summary: loadedCheerio('div.desc-text').text().trim(), - status: loadedCheerio('h3:contains("Status")').next().text(), - chapters: [], - }; - - novel.author = loadedCheerio('h3:contains("Author")') - .parent() - .contents() - .text() - .replace('Author:', ''); - - novel.genres = loadedCheerio('h3:contains("Genre")') - .siblings() - .map((i, el) => loadedCheerio(el).text()) - .toArray() - .join(','); - - const novelId = loadedCheerio('#rating').attr('data-novel-id')!; - const chapter: Plugin.ChapterItem[] = []; - - const getChapters = async (id: string) => { - const chapterListUrl = this.site + 'ajax/chapter-option?novelId=' + id; - - const data = await fetchApi(chapterListUrl); - const chapterlist = await data.text(); - - loadedCheerio = parseHTML(chapterlist); - - loadedCheerio('select > option').each(function () { - const chapterName = loadedCheerio(this).text(); - const chapterUrl = loadedCheerio(this).attr('value')?.slice(1); - if (!chapterUrl) return; - - chapter.push({ - name: chapterName, - path: chapterUrl, - }); - }); - return chapter; - }; - - novel.chapters = await getChapters(novelId); - - return novel; - } - - async parseChapter(chapterPath: string): Promise { - const result = await fetchApi(this.site + chapterPath); - const body = await result.text(); - - const loadedCheerio = parseHTML(body); - - loadedCheerio('#chapter-content div.ads').remove(); - const chapterText = loadedCheerio('#chapter-content').html() || ''; - - return chapterText.replace( - /If you find any errors \(\s*Ads popup, ads redirect, broken links, non-standard content, etc\.\.\s*\), Please let us know \S* report chapter \S* so we can fix it as soon as possible\./, - '', - ); - } - - async searchNovels( - searchTerm: string, - page: number, - ): Promise { - const searchUrl = `${this.site}search?keyword=${encodeURIComponent(searchTerm)}&page=${page}`; - - const result = await fetchApi(searchUrl); - const body = await result.text(); - - const loadedCheerio = parseHTML(body); - return this.parseNovels(loadedCheerio); - } - - filters = { - sort: { - value: 'most-popular', - label: 'Sort by', - options: [ - { label: 'Latest Release', value: 'latest-release-novel' }, - { label: 'Hot Novel', value: 'hot-novel' }, - { label: 'Completed Novel', value: 'completed-novel' }, - { label: 'Most Popular', value: 'most-popular' }, - ], - type: FilterTypes.Picker, - }, - genre: { - value: '', - label: 'Genres', - options: [ - { label: 'None', value: '' }, - { label: 'Shounen', value: 'Shounen' }, - { label: 'Harem', value: 'Harem' }, - { label: 'Comedy', value: 'Comedy' }, - { label: 'Martial Arts', value: 'Martial+Arts' }, - { label: 'School Life', value: 'School+Life' }, - { label: 'Mystery', value: 'Mystery' }, - { label: 'Shoujo', value: 'Shoujo' }, - { label: 'Romance', value: 'Romance' }, - { label: 'Sci-fi', value: 'Sci-fi' }, - { label: 'Gender Bender', value: 'Gender+Bender' }, - { label: 'Mature', value: 'Mature' }, - { label: 'Fantasy', value: 'Fantasy' }, - { label: 'Horror', value: 'Horror' }, - { label: 'Drama', value: 'Drama' }, - { label: 'Tragedy', value: 'Tragedy' }, - { label: 'Supernatural', value: 'Supernatural' }, - { label: 'Ecchi', value: 'Ecchi' }, - { label: 'Xuanhuan', value: 'Xuanhuan' }, - { label: 'Adventure', value: 'Adventure' }, - { label: 'Action', value: 'Action' }, - { label: 'Psychological', value: 'Psychological' }, - { label: 'Xianxia', value: 'Xianxia' }, - { label: 'Wuxia', value: 'Wuxia' }, - { label: 'Historical', value: 'Historical' }, - { label: 'Slice of Life', value: 'Slice+of+Life' }, - { label: 'Seinen', value: 'Seinen' }, - { label: 'Lolicon', value: 'Lolicon' }, - { label: 'Adult', value: 'Adult' }, - { label: 'Josei', value: 'Josei' }, - { label: 'Sports', value: 'Sports' }, - { label: 'Smut', value: 'Smut' }, - { label: 'Mecha', value: 'Mecha' }, - { label: 'Yaoi', value: 'Yaoi' }, - { label: 'Shounen Ai', value: 'Shounen+Ai' }, - { label: 'History', value: 'History' }, - { label: 'Martial', value: 'Martial' }, - ], - type: FilterTypes.Picker, - }, - } satisfies Filters; -} - -export default new NovelFull();