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