Multisrc Readnovelfull: add boxnovel and plugins (#1656)

* base commit

* some changes

* remove pagePlugin
This commit is contained in:
K1ngfish3r
2025-04-27 12:08:27 +05:00
committed by GitHub
parent 309b91b27e
commit dfb2a01e7a
13 changed files with 1168 additions and 391 deletions

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,240 +0,0 @@
{
"filters": {
"genre[]": {
"type": "Checkbox",
"label": "Genre",
"value": [],
"options": [
{
"label": "Action",
"value": "action"
},
{
"label": "Adventure",
"value": "adventure"
},
{
"label": "Comedy",
"value": "comedy"
},
{
"label": "Drama",
"value": "drama"
},
{
"label": "Eastern",
"value": "eastern"
},
{
"label": "Ecchi",
"value": "ecchi"
},
{
"label": "Fantasy",
"value": "fantasy"
},
{
"label": "Gender Bender",
"value": "gender-bender"
},
{
"label": "Harem",
"value": "harem"
},
{
"label": "Historical",
"value": "historical"
},
{
"label": "Horror",
"value": "horror"
},
{
"label": "Josei",
"value": "josei"
},
{
"label": "Martial Arts",
"value": "martial-arts"
},
{
"label": "Mature",
"value": "mature"
},
{
"label": "Mecha",
"value": "mecha"
},
{
"label": "Mystery",
"value": "mystery"
},
{
"label": "Psychological",
"value": "psychological"
},
{
"label": "Romance",
"value": "romance"
},
{
"label": "School Life",
"value": "school-life"
},
{
"label": "Sci-fi",
"value": "sci-fi"
},
{
"label": "Seinen",
"value": "seinen"
},
{
"label": "Shoujo",
"value": "shoujo"
},
{
"label": "Shounen",
"value": "shounen"
},
{
"label": "Slice of Life",
"value": "slice-of-life"
},
{
"label": "Smut",
"value": "smut"
},
{
"label": "Sports",
"value": "sports"
},
{
"label": "Supernatural",
"value": "supernatural"
},
{
"label": "Tragedy",
"value": "tragedy"
},
{
"label": "Wuxia",
"value": "wuxia"
},
{
"label": "Xianxia",
"value": "xianxia"
},
{
"label": "Xuanhuan",
"value": "xuanhuan"
},
{
"label": "Yaoi",
"value": "yaoi"
}
]
},
"op": {
"type": "Switch",
"label": "having all selected genres",
"value": false
},
"author": {
"type": "Text",
"label": "Author",
"value": ""
},
"artist": {
"type": "Text",
"label": "Artist",
"value": ""
},
"release": {
"type": "Text",
"label": "Year of Released",
"value": ""
},
"adult": {
"type": "Picker",
"label": "Adult content",
"value": "",
"options": [
{
"label": "All",
"value": ""
},
{
"label": "None adult content",
"value": "0"
},
{
"label": "Only adult content",
"value": "1"
}
]
},
"status[]": {
"type": "Checkbox",
"label": "Status",
"value": [],
"options": [
{
"label": "OnGoing",
"value": "on-going"
},
{
"label": "Completed",
"value": "end"
},
{
"label": "Canceled",
"value": "canceled"
},
{
"label": "On Hold",
"value": "on-hold"
},
{
"label": "Upcoming",
"value": "upcoming"
}
]
},
"m_orderby": {
"type": "Picker",
"label": "Order by",
"value": "",
"options": [
{
"label": "Relevance",
"value": ""
},
{
"label": "Latest",
"value": "latest"
},
{
"label": "A-Z",
"value": "alphabet"
},
{
"label": "Rating",
"value": "rating"
},
{
"label": "Trending",
"value": "trending"
},
{
"label": "Most Views",
"value": "views"
},
{
"label": "New",
"value": "new-manga"
}
]
}
}
}
-10
View File
@@ -1,14 +1,4 @@
[ [
{
"id": "boxnovel",
"sourceSite": "https://novgo.co/",
"sourceName": "BoxNovel",
"options": {
"useNewChapterEndpoint": true,
"customJs": "chapterText.find('div:has(a)').remove()",
"versionIncrements": 6
}
},
{ {
"id": "novelTL", "id": "novelTL",
"sourceSite": "https://noveltranslate.com/", "sourceSite": "https://noveltranslate.com/",
+31
View File
@@ -0,0 +1,31 @@
# Readnovelfull multisrc generator
## Compatiblity
### This generator is brought over from the madara generator one folder away
it should(!) work for all sites that uses this theme
## Add a new source
### sources.json
To add a new source you need to add it to sources.json:
- id: the id of the source (something unique)
- 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
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
### filters
To add filters to a source you need to run the script "get_filters.js" \
(`npx node scripts/multisrc/readnovelfull/get_filters.js`
(if you are at the root of the project) (and you have ran "npm install" before))
and follow the instructions (url is easier and faster but sometimes it doesn't work)
@@ -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": "Reincarnation",
"value": "genre/Reincarnation"
}
]
}
}
}
@@ -0,0 +1,254 @@
{
"filters": {
"type": {
"type": "Picker",
"label": "Novel Listing",
"value": "sort/nov-love-popular",
"options": [
{
"label": "Hot Novel",
"value": "sort/nov-love-hot"
},
{
"label": "Completed Novel",
"value": "sort/nov-love-complete"
},
{
"label": "Most Popular",
"value": "sort/nov-love-popular"
}
]
},
"genres": {
"type": "Picker",
"label": "Genre",
"value": "",
"options": [
{
"label": "Action",
"value": "nov-love-genres/action"
},
{
"label": "Adventure",
"value": "nov-love-genres/adventure"
},
{
"label": "Anime & comics",
"value": "nov-love-genres/anime-&-comics"
},
{
"label": "Comedy",
"value": "nov-love-genres/comedy"
},
{
"label": "Drama",
"value": "nov-love-genres/drama"
},
{
"label": "Eastern",
"value": "nov-love-genres/eastern"
},
{
"label": "Fan-fiction",
"value": "nov-love-genres/fan-fiction"
},
{
"label": "Fanfiction",
"value": "nov-love-genres/fanfiction"
},
{
"label": "Fantasy",
"value": "nov-love-genres/fantasy"
},
{
"label": "Game",
"value": "nov-love-genres/game"
},
{
"label": "Games",
"value": "nov-love-genres/games"
},
{
"label": "Gender bender",
"value": "nov-love-genres/gender-bender"
},
{
"label": "General",
"value": "nov-love-genres/general"
},
{
"label": "Harem",
"value": "nov-love-genres/harem"
},
{
"label": "Historical",
"value": "nov-love-genres/historical"
},
{
"label": "Horror",
"value": "nov-love-genres/horror"
},
{
"label": "Isekai",
"value": "nov-love-genres/isekai"
},
{
"label": "Josei",
"value": "nov-love-genres/josei"
},
{
"label": "Litrpg",
"value": "nov-love-genres/litrpg"
},
{
"label": "Magic",
"value": "nov-love-genres/magic"
},
{
"label": "Magical realism",
"value": "nov-love-genres/magical-realism"
},
{
"label": "Martial arts",
"value": "nov-love-genres/martial-arts"
},
{
"label": "Mature",
"value": "nov-love-genres/mature"
},
{
"label": "Mecha",
"value": "nov-love-genres/mecha"
},
{
"label": "Modern life",
"value": "nov-love-genres/modern-life"
},
{
"label": "Myster",
"value": "nov-love-genres/myster"
},
{
"label": "Mystery",
"value": "nov-love-genres/mystery"
},
{
"label": "Other",
"value": "nov-love-genres/other"
},
{
"label": "Psychological",
"value": "nov-love-genres/psychological"
},
{
"label": "Reincarnation",
"value": "nov-love-genres/reincarnation"
},
{
"label": "Report story",
"value": "nov-love-genres/report-story"
},
{
"label": "Romance",
"value": "nov-love-genres/romance"
},
{
"label": "Romance.smut",
"value": "nov-love-genres/romance.smut"
},
{
"label": "School life",
"value": "nov-love-genres/school-life"
},
{
"label": "Sci-fi",
"value": "nov-love-genres/sci-fi"
},
{
"label": "Seinen",
"value": "nov-love-genres/seinen"
},
{
"label": "Shoujo",
"value": "nov-love-genres/shoujo"
},
{
"label": "Shoujo ai",
"value": "nov-love-genres/shoujo-ai"
},
{
"label": "Shounen",
"value": "nov-love-genres/shounen"
},
{
"label": "Shounen ai",
"value": "nov-love-genres/shounen-ai"
},
{
"label": "Slice of life",
"value": "nov-love-genres/slice-of-life"
},
{
"label": "Smut",
"value": "nov-love-genres/smut"
},
{
"label": "Sports",
"value": "nov-love-genres/sports"
},
{
"label": "Supernatural",
"value": "nov-love-genres/supernatural"
},
{
"label": "System",
"value": "nov-love-genres/system"
},
{
"label": "Thriller",
"value": "nov-love-genres/thriller"
},
{
"label": "Tragedy",
"value": "nov-love-genres/tragedy"
},
{
"label": "Urban",
"value": "nov-love-genres/urban"
},
{
"label": "Urban life",
"value": "nov-love-genres/urban-life"
},
{
"label": "Video games",
"value": "nov-love-genres/video-games"
},
{
"label": "War",
"value": "nov-love-genres/war"
},
{
"label": "Wuxia",
"value": "nov-love-genres/wuxia"
},
{
"label": "Xianxia",
"value": "nov-love-genres/xianxia"
},
{
"label": "Xuanhuan",
"value": "nov-love-genres/xuanhuan"
},
{
"label": "Yaoi",
"value": "nov-love-genres/yaoi"
},
{
"label": "Yuri",
"value": "nov-love-genres/yuri"
}
]
}
}
}
@@ -0,0 +1,130 @@
{
"filters": {
"type": {
"type": "Picker",
"label": "Novel Listing",
"value": "hot_novel",
"options": [
{
"label": "Hot Novel",
"value": "hot_novel"
},
{
"label": "Novel Completed",
"value": "completed_novel"
}
]
},
"genres": {
"type": "Picker",
"label": "Genre",
"value": "",
"options": [
{
"label": "Fantasy",
"value": "60"
},
{
"label": "Action",
"value": "132"
},
{
"label": "Sci-fi",
"value": "61"
},
{
"label": "Romance",
"value": "59"
},
{
"label": "Adventure",
"value": "62"
},
{
"label": "Xuanhuan",
"value": "64"
},
{
"label": "Modern",
"value": "66"
},
{
"label": "Mystery",
"value": "63"
},
{
"label": "Romance",
"value": "68"
},
{
"label": "Fantasy",
"value": "70"
},
{
"label": "Historical",
"value": "74"
},
{
"label": "Sci-fi",
"value": "75"
},
{
"label": "Xuanhuan",
"value": "76"
},
{
"label": "Mystery",
"value": "77"
},
{
"label": "Adventure",
"value": "116"
},
{
"label": "LGBT+",
"value": "182"
},
{
"label": "Fantasy Romance",
"value": "134"
},
{
"label": "Video Games",
"value": "243"
},
{
"label": "Sci-fi Romance",
"value": "252"
},
{
"label": "Historical Romance",
"value": "256"
},
{
"label": "Magical Realism",
"value": "331"
},
{
"label": "Eastern Fantasy",
"value": "334"
},
{
"label": "Contemporary Romance",
"value": "344"
},
{
"label": "Games",
"value": "503"
},
{
"label": "Urban",
"value": "504"
},
{
"label": "Harem",
"value": "517"
}
]
}
}
}
@@ -0,0 +1,174 @@
{
"filters": {
"type": {
"type": "Picker",
"label": "Novel Listing",
"value": "novel-list/most-popular-novel",
"options": [
{
"label": "Hot Novel",
"value": "novel-list/hot-novel"
},
{
"label": "Completed Novel",
"value": "novel-list/completed-novel"
},
{
"label": "Most Popular",
"value": "novel-list/most-popular-novel"
}
]
},
"genres": {
"type": "Picker",
"label": "Genre",
"value": "",
"options": [
{
"label": "Action",
"value": "genres/action"
},
{
"label": "Adult",
"value": "genres/adult"
},
{
"label": "Adventure",
"value": "genres/adventure"
},
{
"label": "Comedy",
"value": "genres/comedy"
},
{
"label": "Drama",
"value": "genres/drama"
},
{
"label": "Eastern",
"value": "genres/eastern"
},
{
"label": "Ecchi",
"value": "genres/ecchi"
},
{
"label": "Fantasy",
"value": "genres/fantasy"
},
{
"label": "Game",
"value": "genres/game"
},
{
"label": "Gender Bender",
"value": "genres/gender+bender"
},
{
"label": "Harem",
"value": "genres/harem"
},
{
"label": "Historical",
"value": "genres/historical"
},
{
"label": "Horror",
"value": "genres/horror"
},
{
"label": "Josei",
"value": "genres/josei"
},
{
"label": "Lolicon",
"value": "genres/lolicon"
},
{
"label": "Martial Arts",
"value": "genres/martial+arts"
},
{
"label": "Mature",
"value": "genres/mature"
},
{
"label": "Mecha",
"value": "genres/mecha"
},
{
"label": "Modern Life",
"value": "genres/modern+life"
},
{
"label": "Mystery",
"value": "genres/mystery"
},
{
"label": "Psychological",
"value": "genres/psychological"
},
{
"label": "Reincarnation",
"value": "genres/reincarnation"
},
{
"label": "Romance",
"value": "genres/romance"
},
{
"label": "School life",
"value": "genres/school+life"
},
{
"label": "Sci-fi",
"value": "genres/sci-fi"
},
{
"label": "Seinen",
"value": "genres/seinen"
},
{
"label": "Shoujo",
"value": "genres/shoujo"
},
{
"label": "Shounen",
"value": "genres/shounen"
},
{
"label": "Slice of Life",
"value": "genres/slice+of+life"
},
{
"label": "Smut",
"value": "genres/smut"
},
{
"label": "Sports",
"value": "genres/sports"
},
{
"label": "Supernatural",
"value": "genres/supernatural"
},
{
"label": "System",
"value": "genres/system"
},
{
"label": "Thriller",
"value": "genres/thriller"
},
{
"label": "Tragedy",
"value": "genres/tragedy"
},
{
"label": "Transmigration",
"value": "genres/transmigration"
}
]
}
}
}
+10 -4
View File
@@ -1,15 +1,21 @@
import { readFileSync } from 'fs'; import list from './sources.json' with { type: 'json' };
import { existsSync, readFileSync } from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
const folder = dirname(fileURLToPath(import.meta.url)); const folder = dirname(fileURLToPath(import.meta.url));
import list from './sources.json' with { type: 'json' };
export const generateAll = function () { export const generateAll = function () {
return list.map(source => { return list.map(source => {
const exist = existsSync(join(folder, 'filters', source.id + '.json'));
if (exist) {
const filters = readFileSync(
join(folder, 'filters', source.id + '.json'),
);
source.filters = JSON.parse(filters).filters;
}
console.log( console.log(
`[readnovelfull] Generating: ${source.id}${' '.repeat(20 - source.id.length)}`, `[readnovelfull] Generating: ${source.id}${' '.repeat(20 - source.id.length)} ${source.filters ? '🔎with filters🔍' : '🚫no filters🚫'}`,
); );
return generator(source); return generator(source);
}); });
@@ -0,0 +1,216 @@
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, baseUrl) {
const $ = cheerio.load(html);
const filters = {
filters: {
'type': {
type: 'Picker',
label: 'Novel Listing',
value: '',
options: [],
},
'genres': {
type: 'Picker',
label: 'Genre',
value: '',
options: [],
},
},
};
const baseSelector = 'div.navbar-collapse li.dropdown';
// --- Type Filters ---
let defaultValue = null;
const typeOptions = [];
$(`${baseSelector}:eq(0) li a`).each((_, el) => {
const $el = $(el);
const title = $el.attr('title');
const label = $el.text().trim();
const href = $el.attr('href');
if (!href || !label || title === 'Latest Release') return;
try {
const fullUrl = new URL(decodeURI(href), baseUrl);
const params = fullUrl.searchParams;
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}"`,
);
}
} catch (error) {
console.error(
`Error processing type URL (${href}) for ${name}: ${error.message}`,
);
}
});
filters.filters.type.options = typeOptions;
filters.filters.type.value = defaultValue ?? (typeOptions[0]?.value || ''); // Set default: Popular > First > Empty
// --- Genres Filters ---
const genreOptions = [];
$(`${baseSelector}:eq(1) li a`).each((_, el) => {
const $el = $(el);
const label = $el.text().trim();
const href = $el.attr('href');
if (!href || !label) return;
try {
const fullUrl = new URL(decodeURI(href), baseUrl);
const params = fullUrl.searchParams;
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}`,
);
}
});
filters.filters.genre.options = genreOptions; // Genre has no default
// --- Validation and Saving ---
if (
filters.filters.type.options.length === 0 ||
filters.filters.genre.options.length === 0
) {
console.warn(
`🚨Warning for ${name}: Type or Genre options might be incomplete or empty. (${path.join(__dirname, 'filters', name + '.json')})🚨`,
);
}
try {
const filtersDir = path.join(__dirname, 'filters');
if (!fs.existsSync(filtersDir))
fs.mkdirSync(filtersDir, { recursive: true });
fs.writeFileSync(
path.join(filtersDir, name + '.json'),
JSON.stringify(filters, null, 2),
);
console.log(`✅Filters created successfully for ${name}✅`);
} catch (error) {
console.error(
`🚨Error writing filters file for ${name}: ${error.message}🚨`,
);
}
}
async function getFiltersFromURL(name, url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`HTTP error! status: ${response.status}, while fetching ${response.url}`,
);
}
const html = await response.text();
try {
getFilters(name, html, url);
} catch (e) {
console.error('Error while getting filters from', url, e);
}
}
async function askGetFilter() {
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) {
console.error(
'Error while getting filters from',
source.sourceSite,
);
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();
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}/?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';
});
}
},
);
},
);
}
askGetFilter();
export { getFiltersFromURL };
+26 -14
View File
@@ -1,34 +1,46 @@
[ [
{ {
"id": "readnovelfull", "id": "readnovelfull",
"sourceSite": "https://readnovelfull.com", "sourceSite": "https://readnovelfull.com/",
"sourceName": "ReadNovelFull", "sourceName": "ReadNovelFull",
"options": { "options": {
"versionIncrements": 1, "latestPage": "novel-list/latest-release-novel",
"popularPage": "novel-list/most-popular-novel?page=%%PAGE%%", "searchPage": "novel-list/search"
"latestPage": "novel-list/latest-release-novel?page=%%PAGE%%",
"searchPage": "novel-list/search?keyword=%%SEARCH%%&page=%%PAGE%%",
"ajax-chapter-list": true
} }
}, },
{ {
"id": "allnovel", "id": "allnovel",
"sourceSite": "https://allnovel.org", "sourceSite": "https://allnovel.org/",
"sourceName": "AllNovel", "sourceName": "AllNovel",
"options": { "options": {
"popularPage": "most-popular?page=%%PAGE%%", "latestPage": "latest-release-novel",
"latestPage": "latest-release-novel?page=%%PAGE%%", "searchPage": "search",
"searchPage": "search?keyword=%%SEARCH%%&page=%%PAGE%%" "chapterListing": "ajax-chapter-option"
} }
}, },
{ {
"id": "lightnovelplus", "id": "lightnovelplus",
"sourceSite": "https://lightnovelplus.com", "sourceSite": "https://lightnovelplus.com/",
"sourceName": "LightNovelPlus", "sourceName": "LightNovelPlus",
"options": { "options": {
"popularPage": "book/bookclass.html?type=hot_novel&language=en&page_num=%%PAGE%%", "novelListing": "book/bookclass.html",
"latestPage": "book/bookclass.html?type=last_release&language=en&page_num=%%PAGE%%", "chapterListing": "get_chapter_list",
"searchPage": "book/search.html?keyword=%%SEARCH%%&page_num=%%PAGE%%&language=en" "searchPage": "book/search.html",
"latestPage": "last_release",
"genreParam": "category_novel",
"pageParam": "page_num",
"langParam": "language",
"urlLangCode": "en",
"ajaxChapterParam": "bookId"
}
},
{
"id": "boxnovel",
"sourceSite": "https://novlove.com/",
"sourceName": "BoxNovel",
"options": {
"latestPage": "sort/nov-love-daily-update",
"searchPage": "search"
} }
} }
] ]
+149 -119
View File
@@ -1,15 +1,23 @@
import { CheerioAPI, load as parseHTML, load } from 'cheerio'; import { CheerioAPI, load } from 'cheerio';
import { fetchApi } from '@libs/fetch'; import { fetchApi } from '@libs/fetch';
import { Plugin } from '@typings/plugin'; import { Plugin } from '@typings/plugin';
import { NovelStatus } from '@libs/novelStatus'; import { NovelStatus } from '@libs/novelStatus';
import { Filters } from '@libs/filterInputs';
type ReadNovelFullOptions = { type ReadNovelFullOptions = {
lang?: string; lang?: string;
versionIncrements?: number; versionIncrements?: number;
popularPage: string;
latestPage: string; latestPage: string;
searchPage: string; searchPage: string;
ajaxChapterList?: boolean; novelListing?: string;
chapterListing?: string;
TypeParam?: string;
genreParam?: string;
genreKey?: string;
pageParam?: string;
langParam?: string;
urlLangCode?: string;
ajaxChapterParam?: string;
}; };
export type ReadNovelFullMetadata = { export type ReadNovelFullMetadata = {
@@ -27,6 +35,7 @@ class ReadNovelFullPlugin implements Plugin.PluginBase {
site: string; site: string;
version: string; version: string;
options: ReadNovelFullOptions; options: ReadNovelFullOptions;
filters?: Filters | undefined;
constructor(metadata: ReadNovelFullMetadata) { constructor(metadata: ReadNovelFullMetadata) {
this.id = metadata.id; this.id = metadata.id;
@@ -34,8 +43,9 @@ class ReadNovelFullPlugin implements Plugin.PluginBase {
this.icon = `multisrc/readnovelfull/${metadata.id.toLowerCase()}/icon.png`; this.icon = `multisrc/readnovelfull/${metadata.id.toLowerCase()}/icon.png`;
this.site = metadata.sourceSite; this.site = metadata.sourceSite;
const versionIncrements = metadata.options?.versionIncrements || 0; const versionIncrements = metadata.options?.versionIncrements || 0;
this.version = `1.0.${1 + versionIncrements}`; this.version = `2.0.${0 + versionIncrements}`;
this.options = metadata.options; this.options = metadata.options;
this.filters = metadata.filters;
} }
async getCheerio(url: string, search: boolean): Promise<CheerioAPI> { async getCheerio(url: string, search: boolean): Promise<CheerioAPI> {
@@ -50,28 +60,39 @@ class ReadNovelFullPlugin implements Plugin.PluginBase {
} }
parseNovels($: CheerioAPI): Plugin.NovelItem[] { parseNovels($: CheerioAPI): Plugin.NovelItem[] {
const novels: Plugin.NovelItem[] = []; const baseUrl = this.site;
$('.list-novel > .row, .list-truyen > .row').each((i, elem) => { const novels = $('.list-novel .row, .list-truyen .row')
const novelName = $(elem).find('h3').text(); .toArray()
let coverUrl = $(elem).find('img').attr('src'); .map(el => {
const novelUrl = this.site + $(elem).find('a').attr('href'); const $el = $(el);
if ( const novelName = $el.find('h3').text()?.trim();
coverUrl && const rawHref = $el.find('a').attr('href');
!(coverUrl.includes('https://') || coverUrl.includes('http://')) const rawCover =
) { $el.find('img').attr('data-src') || $el.find('img').attr('src');
coverUrl = this.site + coverUrl;
}
if (novelUrl && novelName.trim()) { if (!novelName || !rawHref) {
novels.push({ return null;
}
let novelPath: string;
let novelCover: string | undefined;
const novelUrlObject = new URL(rawHref, baseUrl);
novelPath = novelUrlObject.pathname.slice(1);
if (rawCover) {
novelCover = new URL(rawCover, baseUrl).href;
}
return {
name: novelName, name: novelName,
cover: coverUrl, path: novelPath,
path: novelUrl.replace(this.site, ''), cover: novelCover,
}); };
} })
}); .filter(Boolean);
return novels; return novels;
} }
@@ -80,51 +101,76 @@ class ReadNovelFullPlugin implements Plugin.PluginBase {
pageNo: number, pageNo: number,
{ filters, showLatestNovels }: Plugin.PopularNovelsOptions, { filters, showLatestNovels }: Plugin.PopularNovelsOptions,
): Promise<Plugin.NovelItem[]> { ): Promise<Plugin.NovelItem[]> {
let url = let url = '';
this.site + const pageParam = this.options.pageParam || 'page';
'/' +
this.options.popularPage.replace('%%PAGE%%', pageNo.toString());
if (!filters) filters = {}; if (this.options.novelListing) {
// URL structure with parameters
const params = new URLSearchParams();
const typeParam = this.options.TypeParam || 'type'; // Default to 'type' if not specified
if (showLatestNovels) { if (showLatestNovels) {
url = params.append(typeParam, this.options.latestPage);
this.site + } else if (filters.genres.value.length) {
'/' + params.append(typeParam, this.options.genreParam!);
this.options.latestPage.replace('%%PAGE%%', pageNo.toString()); const genreKey = this.options.genreKey || 'id'; // Default to 'id' if not specified
params.append(genreKey, filters.genres.value);
} else {
params.append(typeParam, filters.type.value);
}
// Add language parameter if specified
if (this.options.langParam && this.options.urlLangCode) {
params.append(this.options.langParam, this.options.urlLangCode);
}
params.append(pageParam, pageNo.toString());
url = `${this.site}${this.options.novelListing}?${params.toString()}`;
} else {
// URL structure with path segments
let basePage = '';
const params = new URLSearchParams({
[pageParam]: pageNo.toString(),
});
if (showLatestNovels) {
basePage = this.options.latestPage;
} else if (filters?.genres?.value?.length) {
basePage = filters.genres.value;
} else {
basePage = filters.type.value;
}
url = `${this.site}${basePage}?${params.toString()}`;
} }
const $ = await this.getCheerio(url, false); const $ = await this.getCheerio(url, false);
return this.parseNovels($); return this.parseNovels($);
} }
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> { async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
let $ = await this.getCheerio(this.site + novelPath, false); let $ = await this.getCheerio(this.site + novelPath, false);
const baseUrl = this.site;
const novel: Plugin.SourceNovel & { totalPages?: number } = { const novel: Partial<Plugin.SourceNovel> = {
path: novelPath.replace(this.site, ''), path: novelPath,
name: 'Untitled',
}; };
novel.name = $('h3.title').first().text().trim(); novel.name = $('h3.title').first().text().trim();
novel.cover = $('.book > img').attr('src'); const coverUrl =
if ( $('.book img').attr('data-src') || $('.book img').attr('src');
novel.cover && if (coverUrl) {
!(novel.cover.includes('https://') || novel.cover.includes('http://')) novel.cover = new URL(coverUrl, baseUrl).href;
) {
novel.cover = this.site + novel.cover;
} }
$('ul.info.info-meta > li, .info > div').each(function () { $('ul.info.info-meta li, .info div').each(function () {
const detailName = $(this) const detailText = $(this).text().split(':');
.find('h3') const detailName = detailText[0].trim().toLowerCase();
.first() const detail = detailText[1]
.text() ?.split(',')
.toLowerCase() .map(g => g.trim())
.replace(':', ''); .join(', ');
const detail = $(this).text().split(':')[1].trim().toLowerCase();
switch (detailName) { switch (detailName) {
case 'author': case 'author':
@@ -134,85 +180,63 @@ class ReadNovelFullPlugin implements Plugin.PluginBase {
novel.genres = detail; novel.genres = detail;
break; break;
case 'status': case 'status':
switch (detail) { const map: Record<string, string> = {
case 'completed': completed: NovelStatus.Completed,
novel.status = NovelStatus.Completed; ongoing: NovelStatus.Ongoing,
break; hiatus: NovelStatus.OnHiatus,
case 'ongoing': };
novel.status = NovelStatus.Ongoing; novel.status = map[detail.toLowerCase()] ?? NovelStatus.Unknown;
break;
case 'hiatus':
novel.status = NovelStatus.OnHiatus;
break;
default:
novel.status = NovelStatus.Unknown;
break;
}
break;
} }
}); });
novel.summary = $('.desc-text').text(); const summaryDiv = $('.desc-text');
const pTagSummary = summaryDiv.find('p');
novel.summary =
pTagSummary.length > 0
? pTagSummary
.map((_, el) => $(el).text().trim())
.get()
.join('\n\n')
: summaryDiv.text().trim(); // --- Else (no <p> tags, assume <br>) ---
const chapters: Plugin.ChapterItem[] = []; const chapters: Plugin.ChapterItem[] = [];
const novelId = $('#rating').attr('data-novel-id'); const dataNovelId = $('#rating').attr('data-novel-id');
if (this.options?.ajaxChapterList) { const idMatch = novelPath.match(/\d+/);
const chaptersUrl = const novelId = dataNovelId || (idMatch ? idMatch[0] : null);
this.site + '/ajax/chapter-archive?novelId=' + novelId;
if (novelId) {
const ajaxParam = this.options.ajaxChapterParam || 'novelId';
const chapterListing =
this.options.chapterListing || 'ajax/chapter-archive';
const params = new URLSearchParams({
[ajaxParam]: novelId.toString(),
});
const chaptersUrl = `${this.site}${chapterListing}?${params.toString()}`;
$ = await this.getCheerio(chaptersUrl, false); $ = await this.getCheerio(chaptersUrl, false);
$('.panel-body') const listing = $('.panel-body li a, select option');
.find('li')
.each(function () {
const chapterName = $(this).find('a').attr('title') || '';
const chapterUrl = $(this).find('a').attr('href') || '';
chapters.push({ listing.each((_, el) => {
name: chapterName, const $el = $(el);
path: chapterUrl, const chapterName = $el.attr('title') || $el.text() || '';
}); const chapterHref = $el.attr('href') || $el.attr('value') || '';
});
} else {
chapters.push(...this.parsePageChapters($));
novel.totalPages = parseInt(
($('ul.pagination > li.last > a').attr('data-page') ??
$('ul.pagination > li.last > a')
.attr('href')
?.match(/\?page_num=(\d+)/)?.[1])!,
);
}
novel.chapters = chapters; const chapterUrlObject = new URL(chapterHref, this.site);
const chapterUrl = chapterUrlObject.pathname.slice(1);
return novel;
}
parsePageChapters($: CheerioAPI) {
const chapters: Plugin.ChapterItem[] = [];
$('.list-chapter')
.find('li')
.each(function () {
const chapterName = $(this).find('a').attr('title') || '';
const chapterUrl = $(this).find('a').attr('href') || '';
chapters.push({ chapters.push({
name: chapterName, name: chapterName,
path: chapterUrl, path: chapterUrl,
releaseTime: null,
}); });
}); });
return chapters; }
} novel.chapters = chapters;
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> { return novel as Plugin.SourceNovel;
const $ = await this.getCheerio(
this.site + novelPath + '?page=' + page,
false,
);
const chapters = this.parsePageChapters($);
return {
chapters,
};
} }
async parseChapter(chapterPath: string): Promise<string> { async parseChapter(chapterPath: string): Promise<string> {
@@ -225,12 +249,18 @@ class ReadNovelFullPlugin implements Plugin.PluginBase {
searchTerm: string, searchTerm: string,
page: number, page: number,
): Promise<Plugin.NovelItem[]> { ): Promise<Plugin.NovelItem[]> {
const url = const pageParam = this.options.pageParam || 'page';
this.site +
'/' + const params = new URLSearchParams({
this.options?.searchPage keyword: searchTerm,
.replace('%%SEARCH%%', encodeURIComponent(searchTerm)) [pageParam]: page.toString(),
.replace('%%PAGE%%', page.toString()); });
if (this.options.langParam) {
params.append(this.options.langParam, this.options.urlLangCode!);
}
const url = `${this.site}${this.options.searchPage}?${params.toString()}`;
const $ = await this.getCheerio(url, true); const $ = await this.getCheerio(url, true);
return this.parseNovels($); return this.parseNovels($);
+4 -4
View File
@@ -7,7 +7,7 @@ class FreeWebNovel implements Plugin.PluginBase {
id = 'FWN.com'; id = 'FWN.com';
name = 'Free Web Novel'; name = 'Free Web Novel';
site = 'https://freewebnovel.com/'; site = 'https://freewebnovel.com/';
version = '2.0.0'; version = '2.0.1';
icon = 'src/en/freewebnovel/icon.png'; icon = 'src/en/freewebnovel/icon.png';
lastSearch: number | null = null; lastSearch: number | null = null;
@@ -219,9 +219,9 @@ class FreeWebNovel implements Plugin.PluginBase {
onend() { onend() {
novel.chapters = chapter; novel.chapters = chapter;
novel.genres = genreArray.join(''); novel.genres = genreArray.join('').trim();
novel.author = authorParts.join(''); novel.author = authorParts.join('').trim();
novel.summary = summaryParts.join('\n'); novel.summary = summaryParts.join('\n\n');
novel.status = statusParts novel.status = statusParts
.join('') .join('')
.toLowerCase() .toLowerCase()