From 7ba30dd2c15aa0c06c7eeb6c6583061ddd02d3d0 Mon Sep 17 00:00:00 2001 From: K1ngfish3r <26593485+K1ngfish3r@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:08:21 +0500 Subject: [PATCH] feat(multisrc): add novelfire and novelphoenix Signed-off-by: K1ngfish3r <26593485+K1ngfish3r@users.noreply.github.com> --- .../multisrc/novelfire/filters/novelfire.json | 454 +++++++++++++++++ .../novelfire/filters/novelphoenix.json | 456 ++++++++++++++++++ plugins/multisrc/novelfire/generator.js | 44 ++ plugins/multisrc/novelfire/get_filters.js | 290 +++++++++++ plugins/multisrc/novelfire/sources.json | 20 + .../novelfire/template.ts} | 228 +++------ .../novelfire}/novelfire/icon.png | Bin .../multisrc/novelfire/novelphoenix/icon.png | Bin 0 -> 18641 bytes 8 files changed, 1322 insertions(+), 170 deletions(-) create mode 100644 plugins/multisrc/novelfire/filters/novelfire.json create mode 100644 plugins/multisrc/novelfire/filters/novelphoenix.json create mode 100644 plugins/multisrc/novelfire/generator.js create mode 100644 plugins/multisrc/novelfire/get_filters.js create mode 100644 plugins/multisrc/novelfire/sources.json rename plugins/{english/novelfire.ts => multisrc/novelfire/template.ts} (68%) rename public/static/{src/en => multisrc/novelfire}/novelfire/icon.png (100%) create mode 100644 public/static/multisrc/novelfire/novelphoenix/icon.png diff --git a/plugins/multisrc/novelfire/filters/novelfire.json b/plugins/multisrc/novelfire/filters/novelfire.json new file mode 100644 index 000000000..f7648df6c --- /dev/null +++ b/plugins/multisrc/novelfire/filters/novelfire.json @@ -0,0 +1,454 @@ +{ + "filters": { + "language": { + "label": "Language", + "value": [], + "options": [ + { + "label": "Chinese Novel", + "value": "1" + }, + { + "label": "Japanese Novel", + "value": "3" + }, + { + "label": "English Novel", + "value": "4" + } + ], + "type": "Checkbox" + }, + "genre_operator": { + "label": "Genres (And/Or/Exclude)", + "value": "and", + "options": [ + { + "label": "AND", + "value": "and" + }, + { + "label": "OR", + "value": "or" + }, + { + "label": "EXCLUDE", + "value": "exclude" + } + ], + "type": "Picker" + }, + "genres": { + "label": "Genres", + "value": [], + "options": [ + { + "label": "Action", + "value": "3" + }, + { + "label": "Adult", + "value": "28" + }, + { + "label": "Adventure", + "value": "4" + }, + { + "label": "Anime", + "value": "46" + }, + { + "label": "Arts", + "value": "47" + }, + { + "label": "Comedy", + "value": "5" + }, + { + "label": "Drama", + "value": "24" + }, + { + "label": "Eastern", + "value": "44" + }, + { + "label": "Ecchi", + "value": "26" + }, + { + "label": "Fan-fiction", + "value": "48" + }, + { + "label": "Fantasy", + "value": "6" + }, + { + "label": "Game", + "value": "19" + }, + { + "label": "Gender Bender", + "value": "25" + }, + { + "label": "Harem", + "value": "7" + }, + { + "label": "Historical", + "value": "12" + }, + { + "label": "Horror", + "value": "37" + }, + { + "label": "Isekai", + "value": "49" + }, + { + "label": "Josei", + "value": "2" + }, + { + "label": "Lgbt+", + "value": "45" + }, + { + "label": "Magic", + "value": "50" + }, + { + "label": "Magical realism", + "value": "51" + }, + { + "label": "Manhua", + "value": "52" + }, + { + "label": "Martial Arts", + "value": "15" + }, + { + "label": "Mature", + "value": "8" + }, + { + "label": "Mecha", + "value": "34" + }, + { + "label": "Military", + "value": "53" + }, + { + "label": "Modern life", + "value": "54" + }, + { + "label": "Movies", + "value": "55" + }, + { + "label": "Mystery", + "value": "16" + }, + { + "label": "Other", + "value": "64" + }, + { + "label": "Psychological", + "value": "9" + }, + { + "label": "Realistic fiction", + "value": "56" + }, + { + "label": "Reincarnation", + "value": "43" + }, + { + "label": "Romance", + "value": "1" + }, + { + "label": "School Life", + "value": "21" + }, + { + "label": "Sci-fi", + "value": "20" + }, + { + "label": "Seinen", + "value": "10" + }, + { + "label": "Shoujo", + "value": "38" + }, + { + "label": "Shoujo ai", + "value": "57" + }, + { + "label": "Shounen", + "value": "17" + }, + { + "label": "Shounen Ai", + "value": "39" + }, + { + "label": "Slice of Life", + "value": "13" + }, + { + "label": "Smut", + "value": "29" + }, + { + "label": "Sports", + "value": "42" + }, + { + "label": "Supernatural", + "value": "18" + }, + { + "label": "System", + "value": "58" + }, + { + "label": "Tragedy", + "value": "32" + }, + { + "label": "Urban", + "value": "63" + }, + { + "label": "Urban life", + "value": "59" + }, + { + "label": "Video games", + "value": "60" + }, + { + "label": "War", + "value": "61" + }, + { + "label": "Wuxia", + "value": "31" + }, + { + "label": "Xianxia", + "value": "23" + }, + { + "label": "Xuanhuan", + "value": "22" + }, + { + "label": "Yaoi", + "value": "14" + }, + { + "label": "Yuri", + "value": "62" + } + ], + "type": "Checkbox" + }, + "chapters": { + "label": "Chapters", + "value": "0", + + "options": [ + { + "label": "All", + "value": "0" + }, + { + "label": "<50", + "value": "1,49" + }, + { + "label": "50-100", + "value": "50,100" + }, + { + "label": "100-200", + "value": "100,200" + }, + { + "label": "200-500", + "value": "200,500" + }, + { + "label": "500-1000", + "value": "500,1000" + }, + { + "label": ">1000", + "value": "1001,1000000" + } + ], + "type": "Picker" + }, + "rating_operator": { + "label": "Rating (Min/Max)", + "value": "min", + + "options": [ + { + "label": "min", + "value": "min" + }, + { + "label": "max", + "value": "max" + } + ], + "type": "Picker" + }, + "rating": { + "label": "Rating", + "value": "0", + + "options": [ + { + "label": "none", + "value": "0" + }, + { + "label": "1", + "value": "1" + }, + { + "label": "2", + "value": "2" + }, + { + "label": "3", + "value": "3" + }, + { + "label": "4", + "value": "4" + }, + { + "label": "5", + "value": "5" + } + ], + "type": "Picker" + }, + "status": { + "label": "Translation Status", + "value": "-1", + + "options": [ + { + "label": "All", + "value": "-1" + }, + { + "label": "Completed", + "value": "1" + }, + { + "label": "Ongoing", + "value": "0" + } + ], + "type": "Picker" + }, + "sort": { + "label": "Sort Results By", + "value": "date", + + "options": [ + { + "label": "Last Updated (Newest)", + "value": "date" + }, + { + "label": "Rank (Top)", + "value": "rank-top" + }, + { + "label": "Rating Score (Top)", + "value": "rating-score-top" + }, + { + "label": "Review Count (Most)", + "value": "review" + }, + { + "label": "Comment Count (Most)", + "value": "comment" + }, + { + "label": "Bookmark Count (Most)", + "value": "bookmark" + }, + { + "label": "Today Views (Most)", + "value": "today-view" + }, + { + "label": "Monthly Views (Most)", + "value": "monthly-view" + }, + { + "label": "Total Views (Most)", + "value": "total-view" + }, + { + "label": "Chapter Count (Most)", + "value": "chapter-count-most" + }, + { + "label": "Title (A>Z)", + "value": "abc" + }, + { + "label": "Title (Z>A)", + "value": "cba" + } + ], + "type": "Picker" + }, + "tagcon": { + "label": "Tags (And/Or)", + "value": "and", + + "options": [ + { + "label": "AND", + "value": "and" + }, + { + "label": "OR", + "value": "or" + } + ], + "type": "Picker" + }, + "author": { + "label": "Author", + "value": "", + "type": "Text" + } + } +} diff --git a/plugins/multisrc/novelfire/filters/novelphoenix.json b/plugins/multisrc/novelfire/filters/novelphoenix.json new file mode 100644 index 000000000..91a484018 --- /dev/null +++ b/plugins/multisrc/novelfire/filters/novelphoenix.json @@ -0,0 +1,456 @@ +{ + "filters": { + "language": { + "label": "Language", + "value": [], + + "options": [ + { + "label": "Chinese Novel", + "value": "1" + }, + { + "label": "Japanese Novel", + "value": "3" + }, + { + "label": "English Novel", + "value": "4" + } + ], + "type": "Checkbox" + }, + "genre_operator": { + "label": "Genres (And/Or/Exclude)", + "value": "and", + "options": [ + { + "label": "AND", + "value": "and" + }, + { + "label": "OR", + "value": "or" + }, + { + "label": "EXCLUDE", + "value": "exclude" + } + ], + "type": "Picker" + }, + "genres": { + "label": "Genres", + "value": [], + + "options": [ + { + "label": "Action", + "value": "3" + }, + { + "label": "Adult", + "value": "28" + }, + { + "label": "Adventure", + "value": "4" + }, + { + "label": "Anime", + "value": "46" + }, + { + "label": "Arts", + "value": "47" + }, + { + "label": "Comedy", + "value": "5" + }, + { + "label": "Drama", + "value": "24" + }, + { + "label": "Eastern", + "value": "44" + }, + { + "label": "Ecchi", + "value": "26" + }, + { + "label": "Fan-fiction", + "value": "48" + }, + { + "label": "Fantasy", + "value": "6" + }, + { + "label": "Game", + "value": "19" + }, + { + "label": "Gender Bender", + "value": "25" + }, + { + "label": "Harem", + "value": "7" + }, + { + "label": "Historical", + "value": "12" + }, + { + "label": "Horror", + "value": "37" + }, + { + "label": "Isekai", + "value": "49" + }, + { + "label": "Josei", + "value": "2" + }, + { + "label": "Lgbt+", + "value": "45" + }, + { + "label": "Magic", + "value": "50" + }, + { + "label": "Magical realism", + "value": "51" + }, + { + "label": "Manhua", + "value": "52" + }, + { + "label": "Martial Arts", + "value": "15" + }, + { + "label": "Mature", + "value": "8" + }, + { + "label": "Mecha", + "value": "34" + }, + { + "label": "Military", + "value": "53" + }, + { + "label": "Modern life", + "value": "54" + }, + { + "label": "Movies", + "value": "55" + }, + { + "label": "Mystery", + "value": "16" + }, + { + "label": "Other", + "value": "64" + }, + { + "label": "Psychological", + "value": "9" + }, + { + "label": "Realistic fiction", + "value": "56" + }, + { + "label": "Reincarnation", + "value": "43" + }, + { + "label": "Romance", + "value": "1" + }, + { + "label": "School Life", + "value": "21" + }, + { + "label": "Sci-fi", + "value": "20" + }, + { + "label": "Seinen", + "value": "10" + }, + { + "label": "Shoujo", + "value": "38" + }, + { + "label": "Shoujo ai", + "value": "57" + }, + { + "label": "Shounen", + "value": "17" + }, + { + "label": "Shounen Ai", + "value": "39" + }, + { + "label": "Slice of Life", + "value": "13" + }, + { + "label": "Smut", + "value": "29" + }, + { + "label": "Sports", + "value": "42" + }, + { + "label": "Supernatural", + "value": "18" + }, + { + "label": "System", + "value": "58" + }, + { + "label": "Tragedy", + "value": "32" + }, + { + "label": "Urban", + "value": "63" + }, + { + "label": "Urban life", + "value": "59" + }, + { + "label": "Video games", + "value": "60" + }, + { + "label": "War", + "value": "61" + }, + { + "label": "Wuxia", + "value": "31" + }, + { + "label": "Xianxia", + "value": "23" + }, + { + "label": "Xuanhuan", + "value": "22" + }, + { + "label": "Yaoi", + "value": "14" + }, + { + "label": "Yuri", + "value": "62" + } + ], + "type": "Checkbox" + }, + "chapters": { + "label": "Chapters", + "value": "0", + + "options": [ + { + "label": "All", + "value": "0" + }, + { + "label": "<50", + "value": "1,49" + }, + { + "label": "50-100", + "value": "50,100" + }, + { + "label": "100-200", + "value": "100,200" + }, + { + "label": "200-500", + "value": "200,500" + }, + { + "label": "500-1000", + "value": "500,1000" + }, + { + "label": ">1000", + "value": "1001,1000000" + } + ], + "type": "Picker" + }, + "rating_operator": { + "label": "Rating (Min/Max)", + "value": "min", + + "options": [ + { + "label": "min", + "value": "min" + }, + { + "label": "max", + "value": "max" + } + ], + "type": "Picker" + }, + "rating": { + "label": "Rating", + "value": "0", + + "options": [ + { + "label": "none", + "value": "0" + }, + { + "label": "1", + "value": "1" + }, + { + "label": "2", + "value": "2" + }, + { + "label": "3", + "value": "3" + }, + { + "label": "4", + "value": "4" + }, + { + "label": "5", + "value": "5" + } + ], + "type": "Picker" + }, + "status": { + "label": "Translation Status", + "value": "-1", + + "options": [ + { + "label": "All", + "value": "-1" + }, + { + "label": "Completed", + "value": "1" + }, + { + "label": "Ongoing", + "value": "0" + } + ], + "type": "Picker" + }, + "sort": { + "label": "Sort Results By", + "value": "date", + + "options": [ + { + "label": "Last Updated (Newest)", + "value": "date" + }, + { + "label": "Rank (Top)", + "value": "rank-top" + }, + { + "label": "Rating Score (Top)", + "value": "rating-score-top" + }, + { + "label": "Review Count (Most)", + "value": "review" + }, + { + "label": "Comment Count (Most)", + "value": "comment" + }, + { + "label": "Bookmark Count (Most)", + "value": "bookmark" + }, + { + "label": "Today Views (Most)", + "value": "today-view" + }, + { + "label": "Monthly Views (Most)", + "value": "monthly-view" + }, + { + "label": "Total Views (Most)", + "value": "total-view" + }, + { + "label": "Chapter Count (Most)", + "value": "chapter-count-most" + }, + { + "label": "Title (A>Z)", + "value": "abc" + }, + { + "label": "Title (Z>A)", + "value": "cba" + } + ], + "type": "Picker" + }, + "tagcon": { + "label": "Tags (And/Or)", + "value": "and", + + "options": [ + { + "label": "AND", + "value": "and" + }, + { + "label": "OR", + "value": "or" + } + ], + "type": "Picker" + }, + "author": { + "label": "Author", + "value": "", + "type": "Text" + } + } +} diff --git a/plugins/multisrc/novelfire/generator.js b/plugins/multisrc/novelfire/generator.js new file mode 100644 index 000000000..387851414 --- /dev/null +++ b/plugins/multisrc/novelfire/generator.js @@ -0,0 +1,44 @@ +import list from './sources.json' with { type: 'json' }; +import { existsSync, readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const folder = dirname(fileURLToPath(import.meta.url)); + +export const generateAll = function () { + return list.map(source => { + const exist = existsSync(join(folder, 'filters', source.id + '.json')); + if (exist) { + const filters = readFileSync( + join(folder, 'filters', source.id + '.json'), + 'utf-8', + ); + // strip `// prettier-ignore` comments (JSONC) since JSON.parse doesn't allow them + source.filters = JSON.parse( + filters.replace(/^\s*\/\/[^\n]*$/gm, ''), + ).filters; + } + console.log( + `[novelfire] Generating: ${source.id}${' '.repeat(20 - source.id.length)} ${source.filters ? '🔎with filters🔍' : '🚫no filters🚫'}`, + ); + return generator(source); + }); +}; + +const generator = function generator(source) { + const NovelFireTemplate = readFileSync(join(folder, 'template.ts'), { + encoding: 'utf-8', + }); + + const pluginScript = ` +${NovelFireTemplate} +const plugin = new NovelFirePlugin(${JSON.stringify(source)}); +export default plugin; + `.trim(); + + return { + lang: source.options?.lang || 'English', + filename: source.sourceName.replace(/[^\w]/g, ''), + pluginScript, + }; +}; diff --git a/plugins/multisrc/novelfire/get_filters.js b/plugins/multisrc/novelfire/get_filters.js new file mode 100644 index 000000000..dee0b60da --- /dev/null +++ b/plugins/multisrc/novelfire/get_filters.js @@ -0,0 +1,290 @@ +import * as fs from 'fs'; +import * as cheerio from 'cheerio'; +import * as path from 'path'; +import * as readline from 'readline'; +import process from 'process'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function getFilters(name, html) { + const $ = cheerio.load(html); + + const filters = { + filters: { + language: { + label: 'Language', + value: [], + options: [], + type: 'Checkbox', + }, + genre_operator: { + label: 'Genres (And/Or/Exclude)', + value: 'and', + options: [], + type: 'Picker', + }, + genres: { + label: 'Genres', + value: [], + options: [], + type: 'Checkbox', + }, + chapters: { + label: 'Chapters', + value: '0', + options: [], + type: 'Picker', + }, + rating_operator: { + label: 'Rating (Min/Max)', + value: 'min', + options: [], + type: 'Picker', + }, + rating: { + label: 'Rating', + value: '0', + options: [], + type: 'Picker', + }, + status: { + label: 'Translation Status', + value: '-1', + options: [], + type: 'Picker', + }, + sort: { + label: 'Sort Results By', + value: 'rank-top', + options: [], + type: 'Picker', + }, + tagcon: { + label: 'Tags (And/Or)', + value: 'and', + options: [], + type: 'Picker', + }, + author: { + label: 'Author', + value: '', + type: 'Text', + }, + }, + }; + + // ==================== Language ==================== + filters.filters.language.label = + $('.search-adv-form > label').text().trim() || 'Language'; + $('input[name="country_id[]"]').each((i, el) => { + filters.filters.language.options.push({ + label: $(el).parent().text().trim() || $(el).next('label').text().trim(), + value: $(el).attr('value'), + }); + }); + + // ==================== Genre operator ==================== + $('select[name="ctgcon"] option').each((i, el) => { + filters.filters.genre_operator.options.push({ + label: $(el).text().trim(), + value: $(el).attr('value'), + }); + }); + const genreOpSelected = + $('select[name="ctgcon"] option:selected').attr('value') || 'and'; + filters.filters.genre_operator.value = genreOpSelected; + + // ==================== Genres ==================== + $('input[name="categories[]"]').each((i, el) => { + filters.filters.genres.options.push({ + label: $(el).parent().text().trim() || $(el).next('label').text().trim(), + value: $(el).attr('value'), + }); + }); + + // ==================== Chapters ==================== + $('select[name="totalchapter"] option').each((i, el) => { + filters.filters.chapters.options.push({ + label: $(el).text().trim(), + value: $(el).attr('value'), + }); + }); + const chaptersSelected = + $('select[name="totalchapter"] option:selected').attr('value') || '0'; + filters.filters.chapters.value = chaptersSelected; + + // ==================== Rating operator ==================== + $('select[name="ratcon"] option').each((i, el) => { + filters.filters.rating_operator.options.push({ + label: $(el).text().trim(), + value: $(el).attr('value'), + }); + }); + const ratconSelected = + $('select[name="ratcon"] option:selected').attr('value') || 'min'; + filters.filters.rating_operator.value = ratconSelected; + + // ==================== Rating ==================== + $('select[name="rating"] option').each((i, el) => { + filters.filters.rating.options.push({ + label: $(el).text().trim(), + value: $(el).attr('value'), + }); + }); + const ratingSelected = + $('select[name="rating"] option:selected').attr('value') || '0'; + filters.filters.rating.value = ratingSelected; + + // ==================== Status ==================== + $('select[name="status"] option').each((i, el) => { + filters.filters.status.options.push({ + label: $(el).text().trim(), + value: $(el).attr('value'), + }); + }); + const statusSelected = + $('select[name="status"] option:selected').attr('value') || '-1'; + filters.filters.status.value = statusSelected; + + // ==================== Sort ==================== + $('select[name="sort"] option').each((i, el) => { + filters.filters.sort.options.push({ + label: $(el).text().trim(), + value: $(el).attr('value'), + }); + }); + const sortSelected = + $('select[name="sort"] option:selected').attr('value') || 'rank-top'; + filters.filters.sort.value = sortSelected; + + // ==================== Tag operator ==================== + $('select[name="tagcon"] option').each((i, el) => { + filters.filters.tagcon.options.push({ + label: $(el).text().trim(), + value: $(el).attr('value'), + }); + }); + const tagconSelected = + $('select[name="tagcon"] option:selected').attr('value') || 'and'; + filters.filters.tagcon.value = tagconSelected; + + // ==================== Author ==================== + filters.filters.author.label = + $('input[name="author"]').prev('label').text().trim() || + $('input[name="author"]').parent().prev().text().trim() || + 'Author'; + + // ==================== checks ==================== + if ( + filters.filters.genres.options.length == 0 || + filters.filters.language.options.length == 0 || + filters.filters.sort.options.length == 0 + ) { + console.error( + `🚨Error in filters for ${name} please fix manually (${path.join(__dirname, 'filters', name + '.json')})🚨`, + ); + } + + // add `// prettier-ignore` above each `options` array so prettier does not + // reformat them (the generator strips these comments before parsing) + const json = JSON.stringify(filters, null, 2).replace( + /\n(\s*)"options": \[/g, + '\n$1// prettier-ignore\n$1"options": [', + ); + + fs.writeFileSync( + path.join(__dirname, 'filters', name + '.json'), + json + '\n', + ); + console.log(`✅Filters created successfully for ${name}✅`); +} + +async function getFiltersFromURL(name, url) { + const response = await fetch(url + '/search-adv'); + if (!response.ok) { + throw new Error( + `HTTP error! status: ${response.status}, while fetching ${response.url}`, + ); + } + const html = await response.text(); + try { + getFilters(name, html); + } catch (e) { + console.error('Error while getting filters from', url); + console.error('(' + e + ')'); + } +} + +async function askGetFilter() { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const EREASE_PREV_LINE = '\x1b[1A\r\x1b[2K'; + await rl.question( + 'Enter the id of the site (same one as in sources.json): ', + async name => { + await 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) { + console.error( + 'Error while getting filters from', + source.sourceSite, + ); + console.log(e.message || e); + } + rl.close(); + } else { + await rl.question( + EREASE_PREV_LINE + + 'Enter the URL (same one as in sources.json): ', + async url => { + rl.close(); + try { + await getFiltersFromURL(name, url); + } catch (e) { + console.error('Error while getting filters from', url); + console.log(e.message || e); + } + }, + ); + } + } else { + process.stdout.write( + EREASE_PREV_LINE + + `Enter the html text from the page at {sourceSite}/search-adv (at the end press ENTER then press CTRL+C) +(to make it faster you can run \`$(".search-adv-form").parent().html()\` in the console to get only the important html part): `, + ); + let html = ''; + rl.on('SIGINT', () => { + console.log('Stopeed reading input, creating filters file'); + getFilters(name, html); + rl.close(); + }); + rl.on('line', line => { + html += line + '\n'; + }); + } + }, + ); + }, + ); +} + +askGetFilter(); + +export { getFiltersFromURL }; diff --git a/plugins/multisrc/novelfire/sources.json b/plugins/multisrc/novelfire/sources.json new file mode 100644 index 000000000..616dad192 --- /dev/null +++ b/plugins/multisrc/novelfire/sources.json @@ -0,0 +1,20 @@ +[ + { + "id": "novelfire", + "sourceSite": "https://novelfire.net/", + "sourceName": "Novel Fire", + "options": { + "lang": "English", + "minorVer": 4, + "versionIncrement": 3 + } + }, + { + "id": "novelphoenix", + "sourceSite": "https://novelphoenix.com/", + "sourceName": "Novel Phoenix", + "options": { + "lang": "English" + } + } +] diff --git a/plugins/english/novelfire.ts b/plugins/multisrc/novelfire/template.ts similarity index 68% rename from plugins/english/novelfire.ts rename to plugins/multisrc/novelfire/template.ts index 59720bb87..9b9d0f13f 100644 --- a/plugins/english/novelfire.ts +++ b/plugins/multisrc/novelfire/template.ts @@ -2,16 +2,33 @@ import { CheerioAPI, load } from 'cheerio'; import { fetchApi } from '@libs/fetch'; import { Plugin } from '@/types/plugin'; import { NovelStatus } from '@libs/novelStatus'; -import { Filters, FilterTypes } from '@libs/filterInputs'; +import { Filters } from '@libs/filterInputs'; import { defaultCover } from '@/types/constants'; import { storage } from '@libs/storage'; -class NovelFire implements Plugin.PluginBase { - id = 'novelfire'; - name = 'Novel Fire'; - version = '1.4.3'; - icon = 'src/en/novelfire/icon.png'; - site = 'https://novelfire.net/'; +type NovelFireOptions = { + lang?: string; + majorVer?: number; + minorVer?: number; + versionIncrement?: number; +}; + +export type NovelFireMetadata = { + id: string; + sourceSite: string; + sourceName: string; + options?: NovelFireOptions; + filters?: Filters; +}; + +export class NovelFirePlugin implements Plugin.PagePlugin { + id: string; + name: string; + icon: string; + site: string; + version: string; + options?: NovelFireOptions; + filters?: Filters; webStorageUtilized = true; novelList = new Set(); draw = 0; @@ -32,6 +49,19 @@ class NovelFire implements Plugin.PluginBase { singlePage = storage.get('singlePage'); pageLength = storage.get('pageLength'); + constructor(metadata: NovelFireMetadata) { + this.id = metadata.id; + this.name = metadata.sourceName; + this.icon = `multisrc/novelfire/${metadata.id.toLowerCase()}/icon.png`; + this.site = metadata.sourceSite; + const majorVer = metadata.options?.majorVer || 1; + const minorVer = metadata.options?.minorVer || 0; + const versionIncrement = metadata.options?.versionIncrement || 0; + this.version = `${majorVer}.${minorVer}.${versionIncrement}`; + this.options = metadata.options; + this.filters = metadata.filters satisfies Filters; + } + async getCheerio(url: string, search: boolean): Promise { const r = await fetchApi(url); if (!r.ok && search != true) @@ -105,19 +135,31 @@ class NovelFire implements Plugin.PluginBase { const url = this.site + 'search-adv'; const params = new URLSearchParams(); - for (const language of filters.language.value) { + for (const language of filters?.language?.value || []) { params.append('country_id[]', language); } - params.append('ctgcon', filters.genre_operator.value); - for (const genre of filters.genres.value) { + params.append('ctgcon', filters?.genre_operator?.value || 'and'); + for (const genre of filters?.genres?.value || []) { params.append('categories[]', genre); } - params.append('totalchapter', filters.chapters.value); - params.append('ratcon', filters.rating_operator.value); - params.append('rating', filters.rating.value); - params.append('status', filters.status.value); - params.append('sort', showLatestNovels ? 'date' : filters.sort.value); - params.append('tagcon', 'and'); + params.append('totalchapter', filters?.chapters?.value || '0'); + params.append('ratcon', filters?.rating_operator?.value || 'min'); + params.append('rating', filters?.rating?.value || '0'); + params.append('status', filters?.status?.value || '-1'); + params.append( + 'sort', + showLatestNovels ? 'date' : filters?.sort?.value || 'rank-top', + ); + params.append('tagcon', filters?.tagcon?.value || 'and'); + for (const tag of filters?.tags?.value || []) { + params.append('tags[]', tag); + } + for (const tag of filters?.tags_excluded?.value || []) { + params.append('tags_excluded[]', tag); + } + if (filters?.author?.value) { + params.append('author', filters.author.value); + } params.append('page', pageNo.toString()); const loadedCheerio = await this.getCheerio( @@ -443,162 +485,8 @@ class NovelFire implements Plugin.PluginBase { page === 1, ); } - - filters = { - sort: { - label: 'Sort Results By', - value: 'rank-top', - options: [ - { label: 'Rank (Top)', value: 'rank-top' }, - { label: 'Rating Score (Top)', value: 'rating-score-top' }, - { label: 'Review Count (Most)', value: 'review' }, - { label: 'Comment Count (Most)', value: 'comment' }, - { label: 'Bookmark Count (Most)', value: 'bookmark' }, - { label: 'Today Views (Most)', value: 'today-view' }, - { label: 'Monthly Views (Most)', value: 'monthly-view' }, - { label: 'Total Views (Most)', value: 'total-view' }, - { label: 'Title (A>Z)', value: 'abc' }, - { label: 'Title (Z>A)', value: 'cba' }, - { label: 'Last Updated (Newest)', value: 'date' }, - { label: 'Chapter Count (Most)', value: 'chapter-count-most' }, - ], - type: FilterTypes.Picker, - }, - status: { - label: 'Translation Status', - value: '-1', - options: [ - { label: 'All', value: '-1' }, - { label: 'Completed', value: '1' }, - { label: 'Ongoing', value: '0' }, - ], - type: FilterTypes.Picker, - }, - genre_operator: { - label: 'Genres (And/Or/Exclude)', - value: 'and', - options: [ - { label: 'AND', value: 'and' }, - { label: 'OR', value: 'or' }, - { label: 'EXCLUDE', value: 'exclude' }, - ], - type: FilterTypes.Picker, - }, - genres: { - label: 'Genres', - value: [], - options: [ - { label: 'Action', value: '3' }, - { label: 'Adult', value: '28' }, - { label: 'Adventure', value: '4' }, - { label: 'Anime', value: '46' }, - { label: 'Arts', value: '47' }, - { label: 'Comedy', value: '5' }, - { label: 'Drama', value: '24' }, - { label: 'Eastern', value: '44' }, - { label: 'Ecchi', value: '26' }, - { label: 'Fan-fiction', value: '48' }, - { label: 'Fantasy', value: '6' }, - { label: 'Game', value: '19' }, - { label: 'Gender Bender', value: '25' }, - { label: 'Harem', value: '7' }, - { label: 'Historical', value: '12' }, - { label: 'Horror', value: '37' }, - { label: 'Isekai', value: '49' }, - { label: 'Josei', value: '2' }, - { label: 'Lgbt+', value: '45' }, - { label: 'Magic', value: '50' }, - { label: 'Magical Realism', value: '51' }, - { label: 'Manhua', value: '52' }, - { label: 'Martial Arts', value: '15' }, - { label: 'Mature', value: '8' }, - { label: 'Mecha', value: '34' }, - { label: 'Military', value: '53' }, - { label: 'Modern Life', value: '54' }, - { label: 'Movies', value: '55' }, - { label: 'Mystery', value: '16' }, - { label: 'Other', value: '64' }, - { label: 'Psychological', value: '9' }, - { label: 'Realistic Fiction', value: '56' }, - { label: 'Reincarnation', value: '43' }, - { label: 'Romance', value: '1' }, - { label: 'School Life', value: '21' }, - { label: 'Sci-fi', value: '20' }, - { label: 'Seinen', value: '10' }, - { label: 'Shoujo', value: '38' }, - { label: 'Shoujo Ai', value: '57' }, - { label: 'Shounen', value: '17' }, - { label: 'Shounen Ai', value: '39' }, - { label: 'Slice of Life', value: '13' }, - { label: 'Smut', value: '29' }, - { label: 'Sports', value: '42' }, - { label: 'Supernatural', value: '18' }, - { label: 'System', value: '58' }, - { label: 'Tragedy', value: '32' }, - { label: 'Urban', value: '63' }, - { label: 'Urban Life', value: '59' }, - { label: 'Video Games', value: '60' }, - { label: 'War', value: '61' }, - { label: 'Wuxia', value: '31' }, - { label: 'Xianxia', value: '23' }, - { label: 'Xuanhuan', value: '22' }, - { label: 'Yaoi', value: '14' }, - { label: 'Yuri', value: '62' }, - ], - type: FilterTypes.CheckboxGroup, - }, - language: { - label: 'Language', - value: [], - options: [ - { label: 'Chinese', value: '1' }, - { label: 'Korean', value: '2' }, - { label: 'Japanese', value: '3' }, - { label: 'English', value: '4' }, - ], - type: FilterTypes.CheckboxGroup, - }, - rating_operator: { - label: 'Rating (Min/Max)', - value: 'min', - options: [ - { label: 'Min', value: 'min' }, - { label: 'Max', value: 'max' }, - ], - type: FilterTypes.Picker, - }, - rating: { - label: 'Rating', - value: '0', - options: [ - { label: 'All', value: '0' }, - { label: '1', value: '1' }, - { label: '2', value: '2' }, - { label: '3', value: '3' }, - { label: '4', value: '4' }, - { label: '5', value: '5' }, - ], - type: FilterTypes.Picker, - }, - chapters: { - label: 'Chapters', - value: '0', - options: [ - { label: 'All', value: '0' }, - { label: '<50', value: '1,49' }, - { label: '50-100', value: '50,100' }, - { label: '100-200', value: '100,200' }, - { label: '200-500', value: '200,500' }, - { label: '500-1000', value: '500,1000' }, - { label: '>1000', value: '1001,1000000' }, - ], - type: FilterTypes.Picker, - }, - } satisfies Filters; } -export default new NovelFire(); - // Custom error for when Novel Fire is rate limiting requests class NovelFireThrottlingError extends Error { constructor(message = 'Novel Fire is rate limiting requests') { diff --git a/public/static/src/en/novelfire/icon.png b/public/static/multisrc/novelfire/novelfire/icon.png similarity index 100% rename from public/static/src/en/novelfire/icon.png rename to public/static/multisrc/novelfire/novelfire/icon.png diff --git a/public/static/multisrc/novelfire/novelphoenix/icon.png b/public/static/multisrc/novelfire/novelphoenix/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ef0be1ba8454360d9cd6e937712980a9ba834c0a GIT binary patch literal 18641 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%0Dkb$$I61?q14=Q96 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 o98K7_