import { CheerioAPI, load as parseHTML } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Filters, FilterTypes } from '@libs/filterInputs';
import { Plugin } from '@/types/plugin';
import { storage } from '@libs/storage';
class Webnovel implements Plugin.PluginBase {
id = 'webnovel';
name = 'Webnovel';
version = '1.0.3';
icon = 'src/en/webnovel/icon.png';
site = 'https://www.webnovel.com';
headers = {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
};
imageRequestInit?: Plugin.ImageRequestInit | undefined = {
headers: {
'referrer': this.site,
},
};
hideLocked = storage.get('hideLocked');
pluginSettings = {
hideLocked: {
value: '',
label: 'Hide locked chapters',
type: 'Switch',
},
};
async parseNovels(
loadedCheerio: CheerioAPI,
category_bool: boolean,
search_bool: boolean,
): Promise {
const selector = category_bool
? '.j_category_wrapper'
: search_bool
? '.j_list_container'
: '';
const attribute = category_bool
? 'data-original'
: search_bool
? 'src'
: '';
return loadedCheerio(`${selector} li`)
.map((i_, ele) => {
const novelName =
loadedCheerio(ele).find('.g_thumb').attr('title') || 'No Title Found';
const novelCover = loadedCheerio(ele)
.find('.g_thumb > img')
.attr(attribute);
const novelPath = loadedCheerio(ele).find('.g_thumb').attr('href');
if (!novelPath) return null;
return {
name: novelName,
cover: 'https:' + novelCover,
path: novelPath,
};
})
.get()
.filter(novel => novel !== null);
}
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions,
): Promise {
if (filters?.fanfic_search.value) {
return this.searchNovelsInternal(
filters.fanfic_search.value,
pageNo,
'fanfic',
);
}
let url = this.site + '/stories/';
const params = new URLSearchParams();
if (showLatestNovels) {
url += `novel?orderBy=5&pageIndex=${pageNo}`;
} else if (filters) {
if (filters.genres_gender.value === '1') {
if (filters.genres_male.value !== '1') {
url += filters.genres_male.value;
} else {
url += 'novel';
params.append('gender', '1');
}
} else if (filters.genres_gender.value === '2') {
if (filters.genres_female.value !== '2') {
url += filters.genres_female.value;
} else {
url += 'novel';
params.append('gender', '2');
}
}
if (filters.type.value !== '3') {
params.append('sourceType', filters.type.value);
} else {
params.append('translateMode', '3');
params.append('sourceType', '1');
}
params.append('bookStatus', filters.status.value);
params.append('orderBy', filters.sort.value);
params.append('pageIndex', pageNo.toString());
url += '?' + params.toString();
} else {
url += `novel?orderBy=1&pageIndex=${pageNo}`;
}
const result = await fetchApi(url, {
headers: this.headers,
});
const body = await result.text();
const loadedCheerio = parseHTML(body);
return this.parseNovels(loadedCheerio, true, false);
}
async parseChapters(novelPath: string): Promise {
const url = this.site + novelPath + '/catalog';
const result = await fetchApi(url, {
headers: this.headers,
});
const body = await result.text();
const loadedCheerio = parseHTML(body);
const chapters: Plugin.ChapterItem[] = [];
loadedCheerio('.volume-item').each((i_v_, ele_v) => {
const originalVolumeName = loadedCheerio(ele_v).first().text().trim();
const volumeNameMatch = originalVolumeName.match(/Volume\s(\d+)/);
const volumeName = volumeNameMatch
? `Volume ${volumeNameMatch[1]}`
: 'Unknown Volume';
loadedCheerio(ele_v)
.find('li')
.each((i_c_, ele_c) => {
const chapterName =
`${volumeName}: ` +
(loadedCheerio(ele_c).find('a').attr('title')?.trim() ||
'No Title Found');
const chapterPath = loadedCheerio(ele_c).find('a').attr('href');
const locked = loadedCheerio(ele_c).find('svg').length;
if (chapterPath && !(locked && this.hideLocked)) {
chapters.push({
name: locked ? `${chapterName} 🔒` : chapterName,
path: chapterPath,
});
}
});
});
return chapters;
}
async parseNovel(novelPath: string): Promise {
const url = this.site + novelPath;
const result = await fetchApi(url, {
headers: this.headers,
});
const body = await result.text();
const loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel = {
path: novelPath,
name: loadedCheerio('.g_thumb > img').attr('alt') || 'No Title Found',
cover: 'https:' + loadedCheerio('.g_thumb > img').attr('src'),
genres: loadedCheerio('.det-hd-detail > .det-hd-tag').attr('title') || '',
summary:
loadedCheerio('.j_synopsis > p')
.find('br')
.replaceWith('\n')
.end()
.text()
.trim() || 'No Summary Found',
author:
loadedCheerio('.det-info .c_s')
.filter((i_, ele) => {
return loadedCheerio(ele).text().trim() === 'Author:';
})
.next()
.text()
.trim() || 'No Author Found',
status:
loadedCheerio('.det-hd-detail svg')
.filter((i_, ele) => {
return loadedCheerio(ele).attr('title') === 'Status';
})
.next()
.text()
.trim() || 'Unknown Status',
chapters: await this.parseChapters(novelPath),
};
return novel;
}
async parseChapter(chapterPath: string): Promise {
const url = this.site + chapterPath;
const result = await fetchApi(url, {
headers: this.headers,
});
const body = await result.text();
const loadedCheerio = parseHTML(body);
const bloatElements = ['.para-comment'];
bloatElements.forEach(tag => loadedCheerio(tag).remove());
return (
loadedCheerio('.cha-tit').html()! + loadedCheerio('.cha-words').html()!
);
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise {
return this.searchNovelsInternal(searchTerm, pageNo);
}
async searchNovelsInternal(
searchTerm: string,
pageNo: number,
type?: string,
): Promise {
searchTerm = searchTerm.replace(/\s+/g, '+');
const url = `${this.site}/search?keywords=${encodeURIComponent(searchTerm)}&pageIndex=${pageNo}${type ? `&type=${type}` : ''}`;
const result = await fetchApi(url, {
headers: this.headers,
});
const body = await result.text();
const loadedCheerio = parseHTML(body);
return this.parseNovels(loadedCheerio, false, true);
}
filters = {
sort: {
label: 'Sort Results By',
value: '1',
options: [
{ label: 'Popular', value: '1' },
{ label: 'Recommended', value: '2' },
{ label: 'Most Collections', value: '3' },
{ label: 'Rating', value: '4' },
{ label: 'Time Updated', value: '5' },
],
type: FilterTypes.Picker,
},
status: {
label: 'Content Status',
value: '0',
options: [
{ label: 'All', value: '0' },
{ label: 'Completed', value: '2' },
{ label: 'Ongoing', value: '1' },
],
type: FilterTypes.Picker,
},
genres_gender: {
label: 'Genres (Male/Female)',
value: '1',
options: [
{ label: 'Male', value: '1' },
{ label: 'Female', value: '2' },
],
type: FilterTypes.Picker,
},
genres_male: {
label: 'Male Genres',
value: '1',
options: [
{ label: 'All', value: '1' },
{ label: 'Action', value: 'novel-action-male' },
{ label: 'Animation, Comics, Games', value: 'novel-acg-male' },
{ label: 'Eastern', value: 'novel-eastern-male' },
{ label: 'Fantasy', value: 'novel-fantasy-male' },
{ label: 'Games', value: 'novel-games-male' },
{ label: 'History', value: 'novel-history-male' },
{ label: 'Horror', value: 'novel-horror-male' },
{ label: 'Realistic', value: 'novel-realistic-male' },
{ label: 'Sci-fi', value: 'novel-scifi-male' },
{ label: 'Sports', value: 'novel-sports-male' },
{ label: 'Urban', value: 'novel-urban-male' },
{ label: 'War', value: 'novel-war-male' },
],
type: FilterTypes.Picker,
},
genres_female: {
label: 'Female Genres',
value: '2',
options: [
{ label: 'All', value: '2' },
{ label: 'Fantasy', value: 'novel-fantasy-female' },
{ label: 'General', value: 'novel-general-female' },
{ label: 'History', value: 'novel-history-female' },
{ label: 'LGBT+', value: 'novel-lgbt-female' },
{ label: 'Sci-fi', value: 'novel-scifi-female' },
{ label: 'Teen', value: 'novel-teen-female' },
{ label: 'Urban', value: 'novel-urban-female' },
],
type: FilterTypes.Picker,
},
type: {
label: 'Content Type',
value: '0',
options: [
{ label: 'All', value: '0' },
{ label: 'Translate', value: '1' },
{ label: 'Original', value: '2' },
{ label: 'MTL (Machine Translation)', value: '3' },
],
type: FilterTypes.Picker,
},
fanfic_search: {
label: 'Search fanfics (Overrides other filters)',
value: '',
type: FilterTypes.TextInput,
},
} satisfies Filters;
}
export default new Webnovel();