feat(multisrc): Add novelfire and novelphoenix (#2367)

Signed-off-by: K1ngfish3r <26593485+K1ngfish3r@users.noreply.github.com>
This commit is contained in:
K1ngfish3r
2026-08-09 08:13:17 +05:00
committed by GitHub
parent a08cfb19a8
commit da83bddf53
8 changed files with 1322 additions and 170 deletions
@@ -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"
}
}
}
@@ -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"
}
}
}
+44
View File
@@ -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,
};
};
+290
View File
@@ -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 };
+20
View File
@@ -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"
}
}
]
+503
View File
@@ -0,0 +1,503 @@
import { CheerioAPI, load } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { NovelStatus } from '@libs/novelStatus';
import { Filters } from '@libs/filterInputs';
import { defaultCover } from '@/types/constants';
import { storage } from '@libs/storage';
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<string>();
draw = 0;
pluginSettings = {
pageLength: {
value: '',
label: 'Page Mode (Change if Broken)',
type: 'Switch',
},
singlePage: {
value: '',
label:
'Force load all chapters on a single page (Slower & use more data)',
type: 'Switch',
},
};
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<CheerioAPI> {
const r = await fetchApi(url);
if (!r.ok && search != true)
throw new Error(
'Could not reach site (' + r.status + ') try to open in webview.',
);
const $ = load(await r.text());
if ($('title').text().includes('Cloudflare')) {
throw new Error('Cloudflare is blocking requests. Try again later.');
}
return $;
}
parseNovels(
loadedCheerio: CheerioAPI,
selector = '.novel-item',
isFirstPage = false,
): Plugin.NovelItem[] {
const novels: Plugin.NovelItem[] = [];
const elements = loadedCheerio(selector).toArray();
for (const el of elements) {
const $el = loadedCheerio(el);
const novelName =
$el.find('a').attr('title') ?? $el.find('h4').text().trim();
const novelPath =
$el.children('a').attr('href') ?? $el.find('h4 a').attr('href');
if (!novelPath) continue;
const path = new URL(novelPath, this.site).pathname.substring(1);
if (!isFirstPage) {
if (this.novelList.has(path)) continue;
this.novelList.add(path);
} else {
this.novelList.add(path);
}
const imgElement = $el.find('.novel-cover > img');
const rawSrc = imgElement.attr('data-src') ?? imgElement.attr('src');
const novelCover = rawSrc
? new URL(rawSrc, this.site).href
: defaultCover;
novels.push({
name: novelName,
cover: novelCover,
path,
});
}
return novels;
}
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
if (pageNo === 1) {
this.novelList.clear();
this.draw = 0;
}
const url = this.site + 'search-adv';
const params = new URLSearchParams();
for (const language of filters?.language?.value || []) {
params.append('country_id[]', language);
}
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 || '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(
`${url}?${params.toString()}`,
false,
);
return this.parseNovels(loadedCheerio, '.novel-item', pageNo === 1);
}
async getAllChapters(
novelPath: string,
post_id: string,
page: string,
): Promise<Plugin.ChapterItem[]> {
const length = this.pageLength ? 100 : -1;
const url = `${this.site}ajax/listChapterDataAjax`;
const start = length === -1 ? 0 : (parseInt(page) - 1) * length;
this.draw++;
const params = new URLSearchParams({
draw: this.draw.toString(),
'columns[0][data]': 'n_sort',
'columns[0][name]': 'cmm_posts_detail.n_sort',
'columns[0][searchable]': 'true',
'columns[0][orderable]': 'true',
'columns[0][search][value]': '',
'columns[0][search][regex]': 'false',
'columns[1][data]': 'bookmark_created_at',
'columns[1][name]': 'bookmark_chapters.created_at',
'columns[1][searchable]': 'false',
'columns[1][orderable]': 'true',
'columns[1][search][value]': '',
'columns[1][search][regex]': 'false',
'order[0][column]': '0',
'order[0][dir]': 'asc',
'order[0][name]': 'cmm_posts_detail.n_sort',
start: start.toString(),
length: length.toString(),
'search[value]': '',
'search[regex]': 'false',
post_id: post_id,
only_bookmark: 'false',
_: Date.now().toString(),
});
const result = await fetchApi(`${url}?${params.toString()}`);
if (result.status === 429) throw new NovelFireThrottlingError();
const body = await result.text();
if (body.includes('You are being rate limited'))
throw new NovelFireThrottlingError();
if (body.includes('Page Not Found 404')) throw new NovelFireAjaxNotFound();
return (JSON.parse(body).data || [])
.flatMap(
(idx: { title?: string; slug: string; n_sort: string | number }) => {
const name = load(idx.title || idx.slug)
.text()
.replace(/[\u200B-\u200D\uFEFF]/g, '')
.trim();
const num = Number(idx.n_sort);
return name && !isNaN(num)
? [
{
name,
path: `${novelPath}/chapter-${num}`,
chapterNumber: num,
},
]
: [];
},
)
.sort(
(a: Plugin.ChapterItem, b: Plugin.ChapterItem) =>
(a.chapterNumber || 0) - (b.chapterNumber || 0),
);
}
async getAllChaptersForce(
novelPath: string,
pages: number,
): Promise<Plugin.ChapterItem[]> {
const pagesArray = Array.from({ length: pages }, (_, i) => i + 1);
const allChapters: Plugin.ChapterItem[] = [];
// When pages > ~30, we get rate limited. To mitigate, split into chunks and retry chunk on rate limit with delay.
const chunkSize = 5; // 5 pages per chunk was tested to be a good balance between speed and rate limiting.
const retryCount = 10;
const sleepTime = 3.5; // Rate limit seems to be around ~10s, so usually 3 retries should be enough for another ~30 pages.
const chaptersArray: Plugin.SourcePage[] = [];
for (let i = 0; i < pagesArray.length; i += chunkSize) {
const pagesArrayChunk = pagesArray.slice(i, i + chunkSize);
const firstPage = pagesArrayChunk[0];
const lastPage = pagesArrayChunk[pagesArrayChunk.length - 1];
let attempt = 0;
while (attempt < retryCount) {
try {
// Parse all pages in chunk in parallel
const chaptersArrayChunk = await Promise.all(
pagesArrayChunk.map(page =>
this.parsePage(novelPath, page.toString()),
),
);
chaptersArray.push(...chaptersArrayChunk);
break;
} catch (err) {
if (err instanceof NovelFireThrottlingError) {
attempt += 1;
console.warn(
`[pages=${firstPage}-${lastPage}] Novel Fire is rate limiting requests. Retry attempt ${attempt + 1} in ${sleepTime} seconds...`,
);
if (attempt === retryCount) {
throw err;
}
// Sleep for X second before retrying
await new Promise(resolve => setTimeout(resolve, sleepTime * 1000));
} else {
throw err;
}
}
}
}
// Merge all chapters into a single array
for (const page of chaptersArray) {
if (page.chapters) {
for (const chapter of page.chapters) {
allChapters.push(chapter);
}
}
}
return allChapters;
}
async parseNovel(
novelPath: string,
): Promise<Plugin.SourceNovel & { totalPages: number }> {
this.draw = 0;
const $ = await this.getCheerio(this.site + novelPath, false);
const baseUrl = this.site;
const post_id = $('#novel-report').attr('report-post_id');
if (post_id) {
storage.set(`${this.id}_${novelPath.split('/').pop()}`, post_id);
}
const novel: Partial<Plugin.SourceNovel & { totalPages: number }> = {
path: novelPath,
totalPages: 1,
};
novel.name =
$('.novel-title').text().trim() ??
$('.cover > img').attr('alt') ??
'No Titled Found';
const coverUrl =
$('.cover > img').attr('data-src') ?? $('.cover > img').attr('src');
if (coverUrl) {
novel.cover = new URL(coverUrl, baseUrl).href;
} else {
novel.cover = defaultCover;
}
novel.genres = $('.categories .property-item')
.map((_, el) => $(el).text())
.toArray()
.join(',');
const summary = $('.summary .content');
summary.find('.expand').remove();
summary.find('br').replaceWith('\n');
summary.find('p').before('\n').after('\n\n');
novel.summary =
summary
.text()
.split('\n')
.map(line => line.trim())
.join('\n')
?.replace(/\n{3,}/g, '\n\n')
.trim() || 'Summary Not Found';
novel.author = $('.author .property-item > span').text();
const rawStatus =
$('.header-stats .ongoing').text() ||
$('.header-stats .completed').text() ||
'Unknown';
const map: Record<string, string> = {
ongoing: NovelStatus.Ongoing,
hiatus: NovelStatus.OnHiatus,
dropped: NovelStatus.Cancelled,
cancelled: NovelStatus.Cancelled,
completed: NovelStatus.Completed,
unknown: NovelStatus.Unknown,
};
novel.status = map[rawStatus.toLowerCase()] ?? NovelStatus.Unknown;
novel.rating = parseFloat($('.nub').text().trim());
const totalChapters = $('.header-stats i.icon-book-open')
.parent()
.text()
.trim();
const length = this.pageLength ? 100 : -1;
novel.totalPages =
length === -1 ? 1 : Math.ceil(parseInt(totalChapters) / length) || 1;
if (novel.totalPages === 1 && post_id) {
novel.chapters = await this.getAllChapters(novelPath, post_id, '1');
}
if (length === 100 && this.singlePage) {
novel.chapters = await this.getAllChaptersForce(
novel.path as string,
novel.totalPages,
);
novel.totalPages = 1;
}
return novel as Plugin.SourceNovel & { totalPages: number };
}
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
const post_id = storage.get(`${this.id}_${novelPath.split('/').pop()}`);
if (post_id && !isNaN(Number(post_id))) {
try {
const chapters = await this.getAllChapters(
novelPath,
String(post_id),
page,
);
return { chapters };
} catch (e) {
// Fallback to scraping if AJAX fails
}
}
// Fallback only works for multiples of 100
const length = this.pageLength ? 100 : -1;
if (length === 100) {
const url = `${this.site}${novelPath}/chapters?page=${page}`;
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = load(body);
const chapters = loadedCheerio('.chapter-list li')
.map((_, ele) => {
const chapterName =
loadedCheerio(ele).find('a').attr('title') || 'No Title Found';
const chapterPath = loadedCheerio(ele).find('a').attr('href');
if (!chapterPath) return null;
return {
name: chapterName,
path: new URL(chapterPath, this.site).pathname.substring(1),
};
})
.get()
.filter(chapter => chapter !== null) as Plugin.ChapterItem[];
return {
chapters,
};
}
return {
chapters: [],
};
}
async parseChapter(chapterPath: string): Promise<string> {
const url = this.site + chapterPath;
const loadedCheerio = await this.getCheerio(url, false);
const chapterText = loadedCheerio('#content');
const odds = chapterText.find(
':not(p, h1, span, i, b, u, img, a, div, strong)',
);
for (const ele of odds.toArray()) {
const tag = ele.name.toString();
if (tag.length > 5 && ele.name.toString().substring(0, 1) == 'nf') {
loadedCheerio(ele).remove();
}
}
return chapterText.html()?.replace(/&nbsp;/g, ' ') || '';
}
async searchNovels(
searchTerm: string,
page: number,
): Promise<Plugin.NovelItem[]> {
if (page === 1) {
this.novelList.clear();
this.draw = 0;
}
const params = new URLSearchParams();
params.append('keyword', searchTerm);
params.append('page', page.toString());
const url = `${this.site}search?${params.toString()}`;
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = load(body);
return this.parseNovels(
loadedCheerio,
'.novel-list.chapters .novel-item',
page === 1,
);
}
}
// Custom error for when Novel Fire is rate limiting requests
class NovelFireThrottlingError extends Error {
constructor(message = 'Novel Fire is rate limiting requests') {
super(message);
this.name = 'NovelFireError';
}
}
class NovelFireAjaxNotFound extends Error {
constructor(message = 'Novel Fire says its Ajax interface is not found') {
super(message);
this.name = 'NovelFireAjaxError';
}
}