Feat: Vite + React (#991)
* remove test_web * integrate reactjs-vite * update lint-staged * simple cors proxy server which handles cookies and redirections * overrider global fetch with proxy call * simple examples for set cookies and fetch request * app bar * add redux * loadPlugins. error: cant load urlencode module * add ts-node for transforming * plugin search & select * plugin slice * accordion components * path item & popular novel * optional for filters * search novel * parse novel * parseChapter * update cookies * default filters * fix: import path * bundler module resolution * new json_plugins * fix: generate multisrc (missing filters) * fix: host scripts * fix: plugin code path * remove typechecking action
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import qs from 'qs';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
|
||||
class LibReadPlugin implements Plugin.PluginBase {
|
||||
id = 'libread';
|
||||
name = 'Lib Read';
|
||||
icon = 'src/en/libread/icon.png';
|
||||
site = 'https://libread.org';
|
||||
version = '1.0.0';
|
||||
|
||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||
const r = await fetchApi(url);
|
||||
if (!r.ok)
|
||||
throw new Error(
|
||||
`Could not reach site (${r.status}: ${r.statusText}) try to open in webview.`,
|
||||
);
|
||||
return parseHTML(await r.text());
|
||||
}
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI): Plugin.NovelItem[] {
|
||||
return loadedCheerio('.li-row')
|
||||
.map((index, element) => ({
|
||||
name: loadedCheerio(element).find('.tit').text() || '',
|
||||
cover: loadedCheerio(element).find('img').attr('src'),
|
||||
path: loadedCheerio(element).find('h3 > a').attr('href') || '',
|
||||
}))
|
||||
.get()
|
||||
.filter(novel => novel.name && novel.path);
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site;
|
||||
if (showLatestNovels) url += '/sort/latest-novels/';
|
||||
else {
|
||||
if (
|
||||
filters &&
|
||||
filters.type_genre &&
|
||||
filters.type_genre.value !== 'all' &&
|
||||
filters.type_genre.value !== 'genre'
|
||||
)
|
||||
url += filters.type_genre.value;
|
||||
else {
|
||||
url += '/most-popular/';
|
||||
if (page != 1) return [];
|
||||
page = 0;
|
||||
}
|
||||
}
|
||||
url += page;
|
||||
|
||||
const $ = await this.getCheerio(url);
|
||||
return this.parseNovels($);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const loadedCheerio = await this.getCheerio(this.site + novelPath);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h1.tit').text(),
|
||||
cover: loadedCheerio('.pic > img').attr('src'),
|
||||
summary: loadedCheerio('.inner').text().trim(),
|
||||
};
|
||||
|
||||
novel.genres = loadedCheerio('[title=Genre]')
|
||||
.next()
|
||||
.text()
|
||||
.replace(/[\t\n]/g, '');
|
||||
|
||||
novel.author = loadedCheerio('[title=Author]')
|
||||
.next()
|
||||
.text()
|
||||
.replace(/[\t\n]/g, '');
|
||||
|
||||
novel.status = loadedCheerio('[title=Status]')
|
||||
.next()
|
||||
.text()
|
||||
.replace(/[\t\n]/g, '');
|
||||
|
||||
novel.genres = loadedCheerio('[title=Genre]')
|
||||
.next()
|
||||
.text()
|
||||
.trim()
|
||||
.replace(/[\t\n]/g, ',')
|
||||
.replace(/, /g, ',');
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = loadedCheerio('#idData > li > a')
|
||||
.map((chapterIndex, element) => ({
|
||||
name:
|
||||
loadedCheerio(element).attr('title') ||
|
||||
'Chapter ' + (chapterIndex + 1),
|
||||
path:
|
||||
loadedCheerio(element).attr('href') ||
|
||||
novelPath.replace(
|
||||
'.html',
|
||||
'/chapter-' + (chapterIndex + 1) + '.html',
|
||||
),
|
||||
releaseTime: null,
|
||||
chapterNumber: chapterIndex + 1,
|
||||
}))
|
||||
.get();
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const loadedCheerio = await this.getCheerio(this.site + chapterPath);
|
||||
|
||||
// remove freewebnovel signature
|
||||
if (loadedCheerio('style').text().includes('p:nth-last-child(1)'))
|
||||
loadedCheerio('div.txt').find('p:last-child').remove();
|
||||
|
||||
const chapterText = loadedCheerio('div.txt').html() || '';
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const r = await fetchApi(this.site + '/search/', {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Referer: this.site,
|
||||
Origin: this.site,
|
||||
},
|
||||
method: 'POST',
|
||||
body: qs.stringify({ searchkey: searchTerm }),
|
||||
});
|
||||
if (!r.ok)
|
||||
throw new Error(
|
||||
'Could not reach site (' + r.status + ') try to open in webview.',
|
||||
);
|
||||
|
||||
const loadedCheerio = parseHTML(await r.text());
|
||||
const alertText =
|
||||
loadedCheerio('script')
|
||||
.text()
|
||||
.match(/alert\((.*?)\)/)?.[1] || '';
|
||||
if (alertText) throw new Error(alertText);
|
||||
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
type_genre: {
|
||||
type: FilterTypes.Picker,
|
||||
label: 'Novel Type/Genre',
|
||||
value: 'all',
|
||||
options: [
|
||||
{ label: 'Tous', value: 'all' },
|
||||
{ label: '═══NOVEL TYPES═══', value: '/sort/latest-release/' },
|
||||
{
|
||||
label: 'Chinese Novel',
|
||||
value: '/sort/latest-release/chinese-novel/',
|
||||
},
|
||||
{ label: 'Korean Novel', value: '/sort/latest-release/korean-novel/' },
|
||||
{
|
||||
label: 'Japanese Novel',
|
||||
value: '/sort/latest-release/japanese-novel/',
|
||||
},
|
||||
{
|
||||
label: 'English Novel',
|
||||
value: '/sort/latest-release/english-novel/',
|
||||
},
|
||||
{ label: '═══GENRES═══', value: 'genre' },
|
||||
{ label: 'Action', value: '/genre/Action/' },
|
||||
{ label: 'Adult', value: '/genre/Adult/' },
|
||||
{ label: 'Adventure', value: '/genre/Adventure/' },
|
||||
{ label: 'Comedy', value: '/genre/Comedy/' },
|
||||
{ label: 'Drama', value: '/genre/Drama/' },
|
||||
{ label: 'Eastern', value: '/genre/Eastern/' },
|
||||
{ label: 'Ecchi', value: '/genre/Ecchi/' },
|
||||
{ label: 'Fantasy', value: '/genre/Fantasy/' },
|
||||
{ label: 'Game', value: '/genre/Game/' },
|
||||
{ label: 'Gender Bender', value: '/genre/Gender+Bender/' },
|
||||
{ label: 'Harem', value: '/genre/Harem/' },
|
||||
{ label: 'Historical', value: '/genre/Historical/' },
|
||||
{ label: 'Horror', value: '/genre/Horror/' },
|
||||
{ label: 'Josei', value: '/genre/Josei/' },
|
||||
{ label: 'Martial Arts', value: '/genre/Martial+Arts/' },
|
||||
{ label: 'Mature', value: '/genre/Mature/' },
|
||||
{ label: 'Mecha', value: '/genre/Mecha/' },
|
||||
{ label: 'Mystery', value: '/genre/Mystery/' },
|
||||
{ label: 'Psychological', value: '/genre/Psychological/' },
|
||||
{ label: 'Reincarnation', value: '/genre/Reincarnation/' },
|
||||
{ label: 'Romance', value: '/genre/Romance/' },
|
||||
{ label: 'School Life', value: '/genre/School+Life/' },
|
||||
{ label: 'Sci-fi', value: '/genre/Sci-fi/' },
|
||||
{ label: 'Seinen', value: '/genre/Seinen/' },
|
||||
{ label: 'Shoujo', value: '/genre/Shoujo/' },
|
||||
{ label: 'Shounen Ai', value: '/genre/Shounen+Ai/' },
|
||||
{ label: 'Shounen', value: '/genre/Shounen/' },
|
||||
{ label: 'Slice of Life', value: '/genre/Slice+of+Life/' },
|
||||
{ label: 'Smut', value: '/genre/Smut/' },
|
||||
{ label: 'Sports', value: '/genre/Sports/' },
|
||||
{ label: 'Supernatural', value: '/genre/Supernatural/' },
|
||||
{ label: 'Tragedy', value: '/genre/Tragedy/' },
|
||||
{ label: 'Wuxia', value: '/genre/Wuxia/' },
|
||||
{ label: 'Xianxia', value: '/genre/Xianxia/' },
|
||||
{ label: 'Xuanhuan', value: '/genre/Xuanhuan/' },
|
||||
{ label: 'Yaoi', value: '/genre/Yaoi/' },
|
||||
],
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new LibReadPlugin();
|
||||
@@ -0,0 +1,205 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import qs from 'qs';
|
||||
|
||||
class NovelsOnline implements Plugin.PluginBase {
|
||||
id = 'NO.net';
|
||||
name = 'novelsOnline';
|
||||
site = 'https://novelsonline.net';
|
||||
icon = 'src/en/novelsonline/icon.png';
|
||||
version = '1.0.0';
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = this.site;
|
||||
if (filters.genre.value !== '') link += `/category/${filters.genre.value}/`;
|
||||
else link += `/top-novel/`;
|
||||
|
||||
link += page;
|
||||
link += `?change_type=${filters.sort.value}`;
|
||||
|
||||
const body = await fetchApi(link).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const novel: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.top-novel-block').each((i, el) => {
|
||||
const novelName = loadedCheerio(el).find('h2').text();
|
||||
const novelCover = loadedCheerio(el)
|
||||
.find('.top-novel-cover img')
|
||||
.attr('src');
|
||||
const novelUrl = loadedCheerio(el).find('h2 a').attr('href');
|
||||
if (!novelUrl) return;
|
||||
|
||||
novel.push({
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
});
|
||||
});
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const result = await fetchApi(this.site + novelPath).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
let $ = parseHTML(result);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: $('h1').text() || 'Untitled',
|
||||
cover: $('.novel-cover').find('a > img').attr('src'),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
$('.novel-detail-item').each((i, el) => {
|
||||
let detailName = $(el).find('h6').text();
|
||||
let detail = $(el).find('.novel-detail-body');
|
||||
|
||||
switch (detailName) {
|
||||
case 'Description':
|
||||
novel.summary = detail.text();
|
||||
break;
|
||||
case 'Genre':
|
||||
novel.genres = detail
|
||||
.find('li')
|
||||
.map((_, el) => $(el).text())
|
||||
.get()
|
||||
.join(', ');
|
||||
break;
|
||||
case 'Author(s)':
|
||||
novel.author = detail
|
||||
.find('li')
|
||||
.map((_, el) => $(el).text())
|
||||
.get()
|
||||
.join(', ');
|
||||
break;
|
||||
case 'Status':
|
||||
novel.status = detail.text().trim();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
novel.chapters = $('ul.chapter-chs > li > a')
|
||||
.map((_, el) => {
|
||||
const chapterUrl = $(el).attr('href');
|
||||
const chapterName = $(el).text();
|
||||
|
||||
return {
|
||||
name: chapterName,
|
||||
path: chapterUrl?.replace(this.site, ''),
|
||||
} as Plugin.ChapterItem;
|
||||
})
|
||||
.get();
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(this.site + chapterPath).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
const loadedCheerio = parseHTML(result);
|
||||
|
||||
const chapterText = loadedCheerio('#contentall').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const result = await fetchApi('https://novelsonline.net/sResults.php', {
|
||||
headers: {
|
||||
Accept: '*/*',
|
||||
'Accept-Language': 'pl,en-US;q=0.7,en;q=0.3',
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
},
|
||||
method: 'POST',
|
||||
body: qs.stringify({ q: searchTerm }),
|
||||
}).then(res => res.text());
|
||||
|
||||
const $ = parseHTML(result);
|
||||
|
||||
const headers = $('li');
|
||||
return headers
|
||||
.map((i, h) => {
|
||||
const novelName = $(h).text();
|
||||
const novelUrl = $(h).find('a').attr('href');
|
||||
const novelCover = $(h).find('img').attr('src');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
return {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
})
|
||||
.get()
|
||||
.filter(sr => sr !== null);
|
||||
}
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
value: 'top_rated',
|
||||
label: 'Sort by',
|
||||
options: [
|
||||
{ label: 'Top Rated', value: 'top_rated' },
|
||||
{ label: 'Most Viewed', value: 'view' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genre: {
|
||||
value: '',
|
||||
label: 'Category',
|
||||
options: [
|
||||
{ label: 'None', value: '' },
|
||||
{ label: 'Action', value: 'action' },
|
||||
{ label: 'Adventure', value: 'adventure' },
|
||||
{ label: 'Celebrity', value: 'celebrity' },
|
||||
{ label: 'Comedy', value: 'comedy' },
|
||||
{ label: 'Drama', value: 'drama' },
|
||||
{ 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: 'Shotacon', value: 'shotacon' },
|
||||
{ label: 'Shoujo', value: 'shoujo' },
|
||||
{ label: 'Shoujo Ai', value: 'shoujo-ai' },
|
||||
{ label: 'Shounen', value: 'shounen' },
|
||||
{ label: 'Shounen Ai', value: 'shounen-ai' },
|
||||
{ label: 'Slice of Life', value: 'slice-of-life' },
|
||||
{ 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' },
|
||||
{ label: 'Yuri', value: 'yuri' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new NovelsOnline();
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
|
||||
class StorySeedlingPlugin implements Plugin.PluginBase {
|
||||
id = 'storyseedling';
|
||||
name = 'StorySeedling';
|
||||
icon = 'src/en/storyseedling/icon.png';
|
||||
site = 'https://storyseedling.com/';
|
||||
version = '1.0.1';
|
||||
|
||||
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
let url = 'https://storyseedling.com/ajax';
|
||||
|
||||
const postUrl = `https://storyseedling.com/browse/`;
|
||||
const body = await fetchApi(postUrl).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
var postValue = loadedCheerio('div[ax-load][x-data]')
|
||||
.attr('x-data')
|
||||
?.replace("browse('", '')
|
||||
.replace("')", '') as string;
|
||||
|
||||
var data = new FormData();
|
||||
data.append('search', '');
|
||||
data.append('orderBy', 'recent');
|
||||
data.append('curpage', pageNo.toString());
|
||||
data.append('post', postValue);
|
||||
data.append('action', 'fetch_browse');
|
||||
|
||||
var response = await (
|
||||
await fetchApi(url, { body: data, method: 'POST' })
|
||||
).json();
|
||||
|
||||
response.data.posts.forEach((element: any) => {
|
||||
const novel = {
|
||||
name: element.title,
|
||||
cover: element.thumbnail,
|
||||
path: element.permalink.replace(this.site, ''),
|
||||
};
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
var site = this.site;
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: '',
|
||||
};
|
||||
|
||||
const body = await fetchApi(this.site + novelPath).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
novel.name = loadedCheerio('h1').text().trim();
|
||||
|
||||
//novel.artist = "";
|
||||
//novel.author = "";
|
||||
novel.cover = loadedCheerio(
|
||||
'img[x-ref="art"].w-full.rounded.shadow-md',
|
||||
).attr('src');
|
||||
if (!novel.cover) {
|
||||
novel.cover = defaultCover;
|
||||
}
|
||||
let genres: string[] = [];
|
||||
loadedCheerio(
|
||||
'section[x-data="{ tab: location.hash.substr(1) || \'chapters\' }"].relative > div > div > div.flex.flex-wrap > a',
|
||||
).each(function () {
|
||||
genres.push(loadedCheerio(this).text().trim());
|
||||
});
|
||||
novel.genres = genres.join(', ');
|
||||
// novel.status = NovelStatus.Completed;
|
||||
novel.summary = loadedCheerio('div.mb-4.order-2:not(.lg\\:grid-in-buttons)')
|
||||
.text()
|
||||
.trim()
|
||||
.replace(/(\r\n|\n|\r)/gm, '');
|
||||
|
||||
let chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio(
|
||||
'div.grid.w-full.grid-cols-1.gap-4.md\\:grid-cols-2 > a',
|
||||
).each(function () {
|
||||
if (loadedCheerio(this).find('> div').length == 2) {
|
||||
return;
|
||||
}
|
||||
let name = loadedCheerio(this).find('.truncate').text().trim();
|
||||
let url = loadedCheerio(this).attr('href') as string;
|
||||
let releaseTime = loadedCheerio(this)
|
||||
.find('div > div > small')
|
||||
.text()
|
||||
.trim();
|
||||
let chapterNumber = name.split('-')[0].trim().split(' ')[1];
|
||||
const chapter: Plugin.ChapterItem = {
|
||||
name: name,
|
||||
path: url.replace(site, ''),
|
||||
releaseTime: releaseTime,
|
||||
chapterNumber: parseInt(chapterNumber),
|
||||
};
|
||||
chapters.push(chapter);
|
||||
});
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchApi(this.site + chapterPath).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
let t = loadedCheerio('div.justify-center > div.mb-4');
|
||||
let chapterText = t.html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
let url = 'https://storyseedling.com/ajax';
|
||||
|
||||
const postUrl = `https://storyseedling.com/browse/`;
|
||||
const body = await fetchApi(postUrl).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
var postValue = loadedCheerio('div[ax-load][x-data]')
|
||||
.attr('x-data')
|
||||
?.replace("browse('", '')
|
||||
.replace("')", '') as string;
|
||||
|
||||
var data = new FormData();
|
||||
data.append('search', searchTerm);
|
||||
data.append('orderBy', 'recent');
|
||||
data.append('curpage', pageNo.toString());
|
||||
data.append('post', postValue);
|
||||
data.append('action', 'fetch_browse');
|
||||
|
||||
var response = await (
|
||||
await fetchApi(url, { body: data, method: 'POST' })
|
||||
).json();
|
||||
|
||||
response.data.posts.forEach((element: any) => {
|
||||
const novel = {
|
||||
name: element.title,
|
||||
cover: element.thumbnail,
|
||||
path: element.permalink.replace(this.site, ''),
|
||||
};
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new StorySeedlingPlugin();
|
||||
@@ -0,0 +1,210 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { isUrlAbsolute } from '@libs/isAbsoluteUrl';
|
||||
|
||||
class AllNovelFullPlugin implements Plugin.PluginBase {
|
||||
id = 'anf.net';
|
||||
name = 'AllNovelFull';
|
||||
icon = 'src/en/allnovelfull/icon.png';
|
||||
site = 'https://allnovelfull.net';
|
||||
version = '1.0.0';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.col-truyen-main .list-truyen .row').each((i, el) => {
|
||||
const novelUrl = loadedCheerio(el)
|
||||
.find('h3.truyen-title > a')
|
||||
.attr('href');
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novelName = loadedCheerio(el).find('h3.truyen-title > a').text();
|
||||
|
||||
let novelCover = loadedCheerio(el).find('img.cover').attr('src');
|
||||
if (novelCover && !isUrlAbsolute(novelCover)) {
|
||||
novelCover = this.site + novelCover;
|
||||
}
|
||||
|
||||
const novel = {
|
||||
path: novelUrl,
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
};
|
||||
novels.push(novel);
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = this.site;
|
||||
|
||||
if (filters.genres.value.length) link += filters.genres.value;
|
||||
else link += filters.order.value;
|
||||
|
||||
link += `?page=${pageNo}`;
|
||||
|
||||
const result = await fetchApi(link);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const url = this.site + novelPath;
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.book > img').attr('alt') || 'Untitled',
|
||||
cover: this.site + loadedCheerio('.book > img').attr('src'),
|
||||
summary: loadedCheerio('.desc-text').text().trim(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
loadedCheerio('.info > div').each((i, el) => {
|
||||
const detailName = loadedCheerio(el).find('h3').text();
|
||||
const detail = loadedCheerio(el)
|
||||
.find('a')
|
||||
.map((a, ex) => loadedCheerio(ex).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
switch (detailName) {
|
||||
case 'Author:':
|
||||
novel.author = detail;
|
||||
break;
|
||||
case 'Status:':
|
||||
novel.status = detail;
|
||||
break;
|
||||
case 'Genre:':
|
||||
novel.genres = detail;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const novelId = loadedCheerio('#rating').attr('data-novel-id');
|
||||
|
||||
const getChapters = async (id: string) => {
|
||||
const chaptersUrl = this.site + '/ajax/chapter-option?novelId=' + id;
|
||||
|
||||
const data = await fetchApi(chaptersUrl);
|
||||
const chapters = await data.text();
|
||||
|
||||
loadedCheerio = parseHTML(chapters);
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('select > option').each((i, el) => {
|
||||
const chapterName = loadedCheerio(el).text();
|
||||
const chapterUrl = loadedCheerio(el).attr('value');
|
||||
|
||||
if (!chapterUrl) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl,
|
||||
});
|
||||
});
|
||||
|
||||
return chapter;
|
||||
};
|
||||
|
||||
if (novelId) {
|
||||
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);
|
||||
|
||||
const chapterText = loadedCheerio('#chapter-content').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}/search?keyword=${searchTerm}&page=${page}`;
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
order: {
|
||||
value: '/most-popular',
|
||||
label: 'Order 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,
|
||||
},
|
||||
genres: {
|
||||
value: '',
|
||||
label: 'Genre',
|
||||
options: [
|
||||
{ label: 'None', value: '' },
|
||||
{ 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: 'Magical Realism', value: '/genre/Magical+Realism' },
|
||||
{ label: 'Video Games', value: '/genre/Video+Games' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new AllNovelFullPlugin();
|
||||
@@ -0,0 +1,272 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { fetchFile } from '@libs/fetch';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
|
||||
class BLN implements Plugin.PluginBase {
|
||||
id = 'BLN';
|
||||
name = 'BestLightNovel';
|
||||
icon = 'src/en/bestlightnovel/icon.png';
|
||||
site = 'https://bestlightnovel.com/';
|
||||
version = '1.0.0';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.update_item.list_category').each((i, el) => {
|
||||
const novelUrl = loadedCheerio(el).find('h3 > a').attr('href');
|
||||
if (!novelUrl) {
|
||||
// TODO: Handle error
|
||||
console.error('No novel url!');
|
||||
return;
|
||||
}
|
||||
|
||||
const novelName = loadedCheerio(el).find('h3 > a').text();
|
||||
const novelCover = loadedCheerio(el).find('img').attr('src');
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
cover: novelCover,
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = this.site + 'novel_list?';
|
||||
link += 'type=' + filters.type.value;
|
||||
link += '&category=' + filters.category.value;
|
||||
link += '&state=' + filters.status.value;
|
||||
link += '&page=' + page;
|
||||
|
||||
const result = await fetch(link);
|
||||
if (!result.ok) {
|
||||
console.error(await result.text());
|
||||
// TODO: Cloudflare protection or other error
|
||||
return [];
|
||||
}
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const result = await fetch(this.site + novelPath);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.truyen_info_right h1').text().trim() || '',
|
||||
cover: loadedCheerio('.info_image img').attr('src') || defaultCover,
|
||||
summary: loadedCheerio('#noidungm').text()?.trim() || '',
|
||||
chapters: [],
|
||||
};
|
||||
loadedCheerio('ul.truyen_info_right > li').each(function () {
|
||||
const detailName = loadedCheerio(this).find('span').text();
|
||||
const detail = loadedCheerio(this)
|
||||
.find('a')
|
||||
.map((a, ex) => loadedCheerio(ex).text())
|
||||
.toArray()
|
||||
.join(', ');
|
||||
|
||||
switch (detailName) {
|
||||
case 'Author(s): ':
|
||||
novel.author = detail;
|
||||
break;
|
||||
case 'GENRES: ':
|
||||
novel.genres = detail;
|
||||
break;
|
||||
case 'STATUS : ':
|
||||
novel.status =
|
||||
detail === 'Ongoing'
|
||||
? NovelStatus.Ongoing
|
||||
: detail === 'Completed'
|
||||
? NovelStatus.Completed
|
||||
: NovelStatus.Unknown;
|
||||
}
|
||||
});
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('.chapter-list div.row').each((i, el) => {
|
||||
const chapterName = loadedCheerio(el).find('a').text().trim();
|
||||
const releaseDate = loadedCheerio(el).find('span:last').text().trim();
|
||||
|
||||
const months = [
|
||||
'jan',
|
||||
'feb',
|
||||
'mar',
|
||||
'apr',
|
||||
'may',
|
||||
'jun',
|
||||
'jul',
|
||||
'aug',
|
||||
'sep',
|
||||
'oct',
|
||||
'nov',
|
||||
'dec',
|
||||
].join('|');
|
||||
const rx = new RegExp(`(${months})-(\\d{1,2})-(\\d{2})`, 'i').exec(
|
||||
releaseDate,
|
||||
);
|
||||
if (!rx) return;
|
||||
const year = 2000 + +rx[3];
|
||||
const month = months.indexOf(rx[1].toLowerCase());
|
||||
const day = +rx[2];
|
||||
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href');
|
||||
if (!chapterUrl) {
|
||||
// TODO: Handle error
|
||||
console.error('No chapter url!');
|
||||
return;
|
||||
}
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
releaseTime: new Date(year, month, day).toISOString(),
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapter.reverse();
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetch(this.site + chapterPath);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const chapterText = loadedCheerio('#vung_doc').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}search_novels/${searchTerm}?page=${page}`;
|
||||
|
||||
const result = await fetch(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
status: {
|
||||
label: 'Status',
|
||||
value: 'all',
|
||||
options: [
|
||||
{ label: 'ALL', value: 'all' },
|
||||
{ label: 'Completed', value: 'completed' },
|
||||
{ label: 'Ongoing', value: 'ongoing' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
type: {
|
||||
value: 'topview',
|
||||
label: 'Type',
|
||||
options: [
|
||||
{ label: 'Recently updated', value: 'latest' },
|
||||
{ label: 'Newest', value: 'newest' },
|
||||
{ label: 'Top view', value: 'topview' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
category: {
|
||||
label: 'Category',
|
||||
value: 'all',
|
||||
options: [
|
||||
{ label: 'ALL', value: 'all' },
|
||||
{ label: 'Action', value: '1' },
|
||||
{ label: 'Adventure', value: '2' },
|
||||
{ label: 'Animals', value: '65' },
|
||||
{ label: 'Arts', value: '40' },
|
||||
{ label: 'Biographies', value: '41' },
|
||||
{ label: 'Business', value: '42' },
|
||||
{ label: 'Chinese', value: '3' },
|
||||
{ label: 'Comedy', value: '4' },
|
||||
{ label: 'Computers', value: '43' },
|
||||
{ label: 'Crafts, Hobbies', value: '45' },
|
||||
{ label: 'Drama', value: '5' },
|
||||
{ label: 'Education', value: '46' },
|
||||
{ label: 'English', value: '6' },
|
||||
{ label: 'Entertainment', value: '47' },
|
||||
{ label: 'Fantasy', value: '7' },
|
||||
{ label: 'Fiction', value: '48' },
|
||||
{ label: 'Gender Bender', value: '8' },
|
||||
{ label: 'Harem', value: '9' },
|
||||
{ label: 'Historical', value: '10' },
|
||||
{ label: 'History', value: '49' },
|
||||
{ label: 'Home', value: '50' },
|
||||
{ label: 'Horror', value: '11' },
|
||||
{ label: 'Humor', value: '51' },
|
||||
{ label: 'Investing', value: '52' },
|
||||
{ label: 'Josei', value: '12' },
|
||||
{ label: 'Korean', value: '13' },
|
||||
{ label: 'Literature', value: '53' },
|
||||
{ label: 'Lolicon', value: '14' },
|
||||
{ label: 'Martial Arts', value: '15' },
|
||||
{ label: 'Mature', value: '16' },
|
||||
{ label: 'Mecha', value: '17' },
|
||||
{ label: 'Memoirs', value: '54' },
|
||||
{ label: 'Mystery', value: '18' },
|
||||
{ label: 'Original', value: '19' },
|
||||
{ label: 'Other Books', value: '66' },
|
||||
{ label: 'Philosophy', value: '55' },
|
||||
{ label: 'Photography', value: '56' },
|
||||
{ label: 'Politics', value: '57' },
|
||||
{ label: 'Professional', value: '58' },
|
||||
{ label: 'Psychological', value: '20' },
|
||||
{ label: 'Reference', value: '59' },
|
||||
{ label: 'Reincarnation', value: '21' },
|
||||
{ label: 'Religion', value: '60' },
|
||||
{ label: 'Romance', value: '22' },
|
||||
{ label: 'School Life', value: '23' },
|
||||
{ label: 'School Stories', value: '67' },
|
||||
{ label: 'Sci-Fi', value: '24' },
|
||||
{ label: 'Seinen', value: '25' },
|
||||
{ label: 'Short Stories', value: '68' },
|
||||
{ label: 'Shotacon', value: '26' },
|
||||
{ label: 'Shoujo', value: '27' },
|
||||
{ label: 'Shoujo Ai', value: '28' },
|
||||
{ label: 'Shounen', value: '29' },
|
||||
{ label: 'Shounen Ai', value: '30' },
|
||||
{ label: 'Slice Of Life', value: '31' },
|
||||
{ label: 'Smut', value: '32' },
|
||||
{ label: 'Social Science', value: '61' },
|
||||
{ label: 'Spirituality', value: '62' },
|
||||
{ label: 'Sports', value: '33' },
|
||||
{ label: 'Supernatural', value: '34' },
|
||||
{ label: 'Teaser', value: '69' },
|
||||
{ label: 'Technical', value: '63' },
|
||||
{ label: 'Technology', value: '64' },
|
||||
{ label: 'Tragedy', value: '35' },
|
||||
{ label: 'Virtual Reality', value: '36' },
|
||||
{ label: 'Wuxia', value: '37' },
|
||||
{ label: 'Xianxia', value: '38' },
|
||||
{ label: 'Xuanhuan', value: '39' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new BLN();
|
||||
@@ -0,0 +1,176 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class ComradeMaoPlugin implements Plugin.PluginBase {
|
||||
id = 'comrademao';
|
||||
name = 'Comrade Mao';
|
||||
site = 'https://comrademao.com/';
|
||||
version = '1.0.0';
|
||||
icon = 'src/en/comrademao/icon.png';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.listupd')
|
||||
.find('div.bs')
|
||||
.each(function () {
|
||||
const novelName = loadedCheerio(this).find('.tt').text().trim();
|
||||
const novelCover = loadedCheerio(this).find('img').attr('src');
|
||||
const novelUrl = loadedCheerio(this).find('a').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
url: novelUrl,
|
||||
};
|
||||
novels.push(novel);
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = this.site;
|
||||
|
||||
link += filters.category.value;
|
||||
|
||||
link += 'page/' + pageNo + '/?post_type=novel';
|
||||
|
||||
const body = await fetchApi(link).then(result => result.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
async parseNovelAndChapters(novelUrl: string): Promise<Plugin.SourceNovel> {
|
||||
const body = await fetchApi(novelUrl).then(result => result.text());
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
url: novelUrl,
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
novel.name = loadedCheerio('.entry-title').text().trim();
|
||||
|
||||
novel.cover = loadedCheerio('div.thumbook > div > img').attr('src');
|
||||
|
||||
loadedCheerio('.infox .wd-full').each((i, el) => {
|
||||
const detailName = loadedCheerio(el).find('b').text();
|
||||
const detail = loadedCheerio(el).find('span').text();
|
||||
|
||||
switch (detailName) {
|
||||
case 'Publisher: ':
|
||||
novel.author = detail.trim();
|
||||
break;
|
||||
case 'Status: ':
|
||||
novel.status = detail.trim();
|
||||
break;
|
||||
case 'Genres:':
|
||||
novel.genres = detail.trim().replace(/ , /g, ',');
|
||||
break;
|
||||
case 'Description: ':
|
||||
novel.summary = detail.trim();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('#chapterlist')
|
||||
.find('li')
|
||||
.each(function () {
|
||||
const chapterUrl = loadedCheerio(this).find('a').attr('href');
|
||||
if (!chapterUrl) return;
|
||||
|
||||
const chapterName = loadedCheerio(this).find('.chapternum').text();
|
||||
let releaseTime = loadedCheerio(this).find('.chapterdate').text();
|
||||
|
||||
if (releaseTime?.includes?.('ago')) {
|
||||
const timeAgo = releaseTime.match(/\d+/)?.[0] || '0';
|
||||
const timeAgoInt = parseInt(timeAgo, 10);
|
||||
|
||||
if (timeAgoInt) {
|
||||
const dayJSDate = dayjs(); // today
|
||||
if (
|
||||
releaseTime.includes('hours ago') ||
|
||||
releaseTime.includes('hour ago')
|
||||
) {
|
||||
dayJSDate.subtract(timeAgoInt, 'hours'); // go back N hours
|
||||
}
|
||||
|
||||
if (
|
||||
releaseTime.includes('days ago') ||
|
||||
releaseTime.includes('day ago')
|
||||
) {
|
||||
dayJSDate.subtract(timeAgoInt, 'days'); // go back N days
|
||||
}
|
||||
|
||||
if (
|
||||
releaseTime.includes('months ago') ||
|
||||
releaseTime.includes('month ago')
|
||||
) {
|
||||
dayJSDate.subtract(timeAgoInt, 'months'); // go back N months
|
||||
}
|
||||
|
||||
releaseTime = dayJSDate.format('LL');
|
||||
}
|
||||
}
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
url: chapterUrl,
|
||||
releaseTime,
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapter.reverse();
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterUrl: string): Promise<string> {
|
||||
const body = await fetchApi(chapterUrl).then(result => result.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const chapterText = loadedCheerio('#chaptercontent').html()?.trim() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}page/${pageNo}/?s=${searchTerm}&post_type=novel`;
|
||||
|
||||
const body = await fetchApi(url).then(result => result.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
category: {
|
||||
value: '',
|
||||
label: 'Type',
|
||||
options: [
|
||||
{ label: 'None', value: '' },
|
||||
{ label: 'Chinese', value: '/mtype/chinese/' },
|
||||
{ label: 'Japanese', value: '/mtype/japanese/' },
|
||||
{ label: 'Korean', value: '/mtype/korean/' },
|
||||
{ label: 'Manhua', value: '/mtype/manhua/' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new ComradeMaoPlugin();
|
||||
@@ -0,0 +1,133 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
|
||||
class DDLPlugin implements Plugin.PluginBase {
|
||||
id = 'DDL.com';
|
||||
name = 'Divine Dao Library';
|
||||
site = 'https://www.divinedaolibrary.com/';
|
||||
version = '1.0.1';
|
||||
icon = 'src/en/divinedaolibrary/icon.png';
|
||||
filters?: undefined;
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI, searchTerm?: string) {
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('#main')
|
||||
.find('li')
|
||||
.each((i, el) => {
|
||||
const novelName = loadedCheerio(el).find('a').text();
|
||||
const novelCover = defaultCover;
|
||||
const novelUrl = loadedCheerio(el).find('a').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
if (searchTerm) {
|
||||
novels = novels.filter(novel =>
|
||||
novel.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
options: Plugin.PopularNovelsOptions,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = this.site + 'novels';
|
||||
|
||||
const body = await fetchApi(link).then(res => res.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);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h1.entry-title').text().trim() || 'Untitled',
|
||||
cover:
|
||||
loadedCheerio('.entry-content').find('img').attr('data-ezsrc') ||
|
||||
defaultCover,
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
novel.summary = loadedCheerio('#main > article > div > p:nth-child(6)')
|
||||
.text()
|
||||
.trim();
|
||||
|
||||
novel.author = loadedCheerio('#main > article > div > h3:nth-child(2)')
|
||||
.text()
|
||||
.replace(/Author:/g, '')
|
||||
.trim();
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('#main')
|
||||
.find('li > span > a')
|
||||
.each((i, el) => {
|
||||
const chapterName = loadedCheerio(el).text().trim();
|
||||
const chapterUrl = loadedCheerio(el).attr('href');
|
||||
|
||||
if (!chapterUrl) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapter;
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(this.site + chapterPath);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const chapterName = loadedCheerio('.entry-title').text().trim();
|
||||
|
||||
let chapterText = loadedCheerio('.entry-content').html();
|
||||
|
||||
if (!chapterText) {
|
||||
chapterText = loadedCheerio('.page-header').html();
|
||||
}
|
||||
|
||||
chapterText = `<p><h1>${chapterName}</h1></p>` + chapterText;
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site + 'novels';
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio, searchTerm);
|
||||
}
|
||||
}
|
||||
|
||||
export default new DDLPlugin();
|
||||
@@ -0,0 +1,202 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
|
||||
class EarlyNovelPlugin implements Plugin.PagePlugin {
|
||||
id = 'earlynovel';
|
||||
name = 'Early Novel';
|
||||
version = '1.0.0';
|
||||
icon = 'src/en/earlynovel/icon.png';
|
||||
site = 'https://earlynovel.net/';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.col-truyen-main > .list-truyen > .row').each((i, el) => {
|
||||
const novelUrl = loadedCheerio(el)
|
||||
.find('h3.truyen-title > a')
|
||||
.attr('href');
|
||||
const novelName = loadedCheerio(el).find('h3.truyen-title > a').text();
|
||||
const novelCover = loadedCheerio(el).find('.lazyimg').attr('data-image');
|
||||
|
||||
if (!novelUrl) return;
|
||||
novels.push({
|
||||
path: novelUrl,
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
});
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
parseChapters(loadedCheerio: CheerioAPI) {
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
loadedCheerio('ul.list-chapter > li').each((i, el) => {
|
||||
const chapterName = loadedCheerio(el).find('.chapter-text').text().trim();
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href')?.slice(1);
|
||||
if (!chapterUrl) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl,
|
||||
});
|
||||
});
|
||||
return chapter;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = this.site;
|
||||
|
||||
if (filters.genres.value.length) link += filters.genres.value;
|
||||
else link += filters.order.value;
|
||||
|
||||
link += `?page=${pageNo}`;
|
||||
|
||||
const body = await fetchApi(link).then(res => res.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
async parseNovel(
|
||||
novelPath: string,
|
||||
): Promise<Plugin.SourceNovel & { totalPages: number }> {
|
||||
const result = await fetchApi(this.site + novelPath);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
loadedCheerio('.glyphicon-menu-right').closest('li').remove();
|
||||
const pagenav = loadedCheerio('.page-nav').prev().find('a');
|
||||
const lastPageStr = pagenav.attr('title')?.match(/(\d+)/);
|
||||
const lastPage = Number(lastPageStr?.[1] || '0');
|
||||
|
||||
const novel: Plugin.SourceNovel & { totalPages: number } = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.book > img').attr('alt') || 'Untitled',
|
||||
cover: loadedCheerio('.book > img').attr('src'),
|
||||
summary: loadedCheerio('.desc-text').text().trim(),
|
||||
chapters: [],
|
||||
totalPages: lastPage,
|
||||
};
|
||||
|
||||
loadedCheerio('.info > div > h3').each(function () {
|
||||
let detailName = loadedCheerio(this).text();
|
||||
let detail = loadedCheerio(this)
|
||||
.siblings()
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
switch (detailName) {
|
||||
case 'Author:':
|
||||
novel.author = detail;
|
||||
break;
|
||||
case 'Status:':
|
||||
novel.status = detail;
|
||||
break;
|
||||
case 'Genre:':
|
||||
novel.genres = detail;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
novel.chapters = this.parseChapters(loadedCheerio);
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
|
||||
const url = this.site + novelPath + '?page=' + page;
|
||||
const body = await fetchApi(url).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const chapters = this.parseChapters(loadedCheerio);
|
||||
return { chapters };
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(this.site + chapterPath);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const chapterText = loadedCheerio('#chapter-c').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}search?keyword=${searchTerm}`;
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
order: {
|
||||
value: '/most-popular',
|
||||
label: 'Order 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,
|
||||
},
|
||||
genres: {
|
||||
value: '',
|
||||
label: 'Genre',
|
||||
options: [
|
||||
{ label: 'None', value: '' },
|
||||
{ label: 'Action', value: '/genre/action-1' },
|
||||
{ label: 'Adult', value: '/genre/adult-2' },
|
||||
{ label: 'Adventure', value: '/genre/adventure-3' },
|
||||
{ label: 'Comedy', value: '/genre/comedy-4' },
|
||||
{ label: 'Drama', value: '/genre/drama-5' },
|
||||
{ label: 'Ecchi', value: '/genre/ecchi-6' },
|
||||
{ label: 'Fantasy', value: '/genre/fantasy-7' },
|
||||
{ label: 'Gender Bender', value: '/genre/gender-bender-8' },
|
||||
{ label: 'Harem', value: '/genre/harem-9' },
|
||||
{ label: 'Historical', value: '/genre/historical-10' },
|
||||
{ label: 'Horror', value: '/genre/horror-11' },
|
||||
{ label: 'Josei', value: '/genre/josei-12' },
|
||||
{ label: 'Martial Arts', value: '/genre/martial-arts-13' },
|
||||
{ label: 'Mature', value: '/genre/mature-14' },
|
||||
{ label: 'Mecha', value: '/genre/mecha-15' },
|
||||
{ label: 'Mystery', value: '/genre/mystery-16' },
|
||||
{ label: 'Psychological', value: '/genre/psychological-17' },
|
||||
{ label: 'Romance', value: '/genre/romance-18' },
|
||||
{ label: 'School Life', value: '/genre/school-life-19' },
|
||||
{ label: 'Sci-fi', value: '/genre/sci-fi-20' },
|
||||
{ label: 'Seinen', value: '/genre/seinen-21' },
|
||||
{ label: 'Shoujo', value: '/genre/shoujo-22' },
|
||||
{ label: 'Shoujo Ai', value: '/genre/shoujo-ai-23' },
|
||||
{ label: 'Shounen', value: '/genre/shounen-24' },
|
||||
{ label: 'Shounen Ai', value: '/genre/shounen-ai-25' },
|
||||
{ label: 'Slice of Life', value: '/genre/slice-of-life-26' },
|
||||
{ label: 'Smut', value: '/genre/smut-27' },
|
||||
{ label: 'Sports', value: '/genre/sports-28' },
|
||||
{ label: 'Supernatural', value: '/genre/supernatural-29' },
|
||||
{ label: 'Tragedy', value: '/genre/tragedy-30' },
|
||||
{ label: 'Wuxia', value: '/genre/wuxia-31' },
|
||||
{ label: 'Xianxia', value: '/genre/xianxia-32' },
|
||||
{ label: 'Xuanhuan', value: '/genre/xuanhuan-33' },
|
||||
{ label: 'Yaoi', value: '/genre/yaoi-34' },
|
||||
{ label: 'Yuri', value: '/genre/yuri-35' },
|
||||
{ label: 'Video Games', value: '/genre/video-games-36' },
|
||||
{ label: 'Magical Realism', value: '/genre/magical-realism-37' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new EarlyNovelPlugin();
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class FaqWikiUs implements Plugin.PluginBase {
|
||||
id = 'FWK.US';
|
||||
name = 'Faq Wiki';
|
||||
site = 'https://faqwiki.us/';
|
||||
version = '1.1.1';
|
||||
icon = 'src/en/faqwikius/icon.png';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI, searchTerm?: string) {
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('figure.wp-block-image').each((index, element) => {
|
||||
const name = loadedCheerio(element).text();
|
||||
let cover = loadedCheerio(element).find('img').attr('data-ezsrc');
|
||||
|
||||
// Remove the appended query string
|
||||
if (cover) {
|
||||
cover = cover.replace(/\?ezimgfmt=.*$/, ''); // Regular expression magic!
|
||||
} else {
|
||||
cover = loadedCheerio(element).find('img').attr('src');
|
||||
}
|
||||
|
||||
const path = loadedCheerio(element)
|
||||
.find('a')
|
||||
.attr('href')
|
||||
?.replace('tp:', 'tps:')
|
||||
?.slice(this.site.length);
|
||||
if (!path) return;
|
||||
|
||||
novels.push({ name, cover, path });
|
||||
});
|
||||
|
||||
if (searchTerm) {
|
||||
novels = novels.filter(novel =>
|
||||
novel.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ showLatestNovels }: Plugin.PopularNovelsOptions,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const body = await fetchApi(this.site).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
async parseNovel(path: string): Promise<Plugin.SourceNovel> {
|
||||
const body = await fetchApi(this.site + path).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path,
|
||||
name: '',
|
||||
};
|
||||
|
||||
novel.name = loadedCheerio('.entry-title')
|
||||
.text()
|
||||
.replace('Novel – All Chapters', '')
|
||||
.trim();
|
||||
|
||||
novel.cover = loadedCheerio('.wp-block-image')
|
||||
.find('img')
|
||||
.attr('data-ezsrc')
|
||||
?.replace(/\?ezimgfmt=.*$/, ''); // Regular expression magic!
|
||||
|
||||
const status = loadedCheerio(
|
||||
"#lcp_instance_0 +:icontains('complete')",
|
||||
).text();
|
||||
novel.status = status ? NovelStatus.Completed : NovelStatus.Ongoing;
|
||||
|
||||
const div = loadedCheerio('.book-review-block__meta-item-value');
|
||||
|
||||
div.html(div.html()?.replace(/(?<=>)([^<]+)(?=<br\s*\/?>)/g, '<p>$1</p>')!);
|
||||
|
||||
loadedCheerio('.book-review-block__meta-item-value strong').each(
|
||||
(i, el) => {
|
||||
const key = loadedCheerio(el)
|
||||
.text()
|
||||
.replace(':', '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const value = loadedCheerio(el).next().text().replace(':', '').trim();
|
||||
|
||||
switch (key) {
|
||||
case 'description':
|
||||
novel.summary = value;
|
||||
break;
|
||||
case 'author(s)':
|
||||
novel.author = value;
|
||||
break;
|
||||
case 'genre':
|
||||
novel.genres = value.split(' ').join(',');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = loadedCheerio(
|
||||
'.lcp_catlist > li > a',
|
||||
)
|
||||
.map((chapterIndex, element) => ({
|
||||
name: loadedCheerio(element)
|
||||
.text()
|
||||
.replace(novel.name + '', '')
|
||||
.replace('Novel' + '', '')
|
||||
.trim(),
|
||||
path: '' + loadedCheerio(element).attr('href')?.slice(this.site.length),
|
||||
chapterNumber: chapterIndex + 1,
|
||||
}))
|
||||
.get();
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchApi(this.site + chapterPath).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
const loadedCheerio = parseHTML(body);
|
||||
loadedCheerio('span').remove();
|
||||
|
||||
const chapterParagraphs = loadedCheerio('.entry-content p');
|
||||
|
||||
let chapterContent; // Variable to store the result
|
||||
|
||||
if (chapterParagraphs.length < 5) {
|
||||
//some chapter in this site store their whole text in 1-4 <p>,
|
||||
chapterContent = chapterParagraphs
|
||||
.map((index, element) => {
|
||||
const text = loadedCheerio(element).html();
|
||||
return text;
|
||||
})
|
||||
.get()
|
||||
.join('\n\n');
|
||||
} else {
|
||||
// Multiple paragraphs case
|
||||
chapterContent = chapterParagraphs
|
||||
.map((index, element) => {
|
||||
const text = loadedCheerio(element).text().trim();
|
||||
return `<p>${text}</p>`;
|
||||
})
|
||||
.get()
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
return chapterContent;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const body = await fetchApi(this.site).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
return this.parseNovels(loadedCheerio, searchTerm);
|
||||
}
|
||||
}
|
||||
|
||||
export default new FaqWikiUs();
|
||||
@@ -0,0 +1,312 @@
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Parser } from 'htmlparser2';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class Foxteller implements Plugin.PluginBase {
|
||||
id = 'foxteller';
|
||||
name = 'Foxteller';
|
||||
site = 'https://www.foxteller.com';
|
||||
version = '1.0.0';
|
||||
icon = 'src/en/foxteller/icon.png';
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url =
|
||||
this.site + '/library?sort=' + filters?.order?.value || 'popularity';
|
||||
|
||||
const body = await fetchApi(url).then(res => res.text());
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
const div = body.match(/<div class="col-md-6">([\s\S]*?)<\/div>/g) || [];
|
||||
div.forEach(elements => {
|
||||
const [, novelUrl, novelName] =
|
||||
elements.match(/<a href="(.*?)" title="(.*?)">/) || [];
|
||||
|
||||
if (novelName && novelUrl) {
|
||||
const novelCover = elements.match(
|
||||
/<img class="img-fluid" src="(.*?)".*>/,
|
||||
);
|
||||
|
||||
novels.push({
|
||||
name: novelName,
|
||||
cover: novelCover?.[1] || defaultCover,
|
||||
path: novelUrl.split('/')[4],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const body = await fetchApi(this.resolveUrl(novelPath)).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: '',
|
||||
genres: '',
|
||||
summary: '',
|
||||
status: '',
|
||||
chapters: [] as Plugin.ChapterItem[],
|
||||
};
|
||||
let isParsingGenres = false;
|
||||
let isReadingGenre = false;
|
||||
let isReadingSummary = false;
|
||||
let isParsingInfo = false;
|
||||
let isReadingInfo = false;
|
||||
let isParsingChapterList = false;
|
||||
let isReadingChapter = false;
|
||||
let isPaidChapter = false;
|
||||
const chapters: Plugin.ChapterItem[] = [];
|
||||
let tempChapter = {} as Plugin.ChapterItem;
|
||||
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
// name and cover
|
||||
if (!novel.cover && attribs['class'] === 'img-fluid') {
|
||||
novel.name = attribs['alt'];
|
||||
novel.cover = attribs['src'] || defaultCover;
|
||||
} // genres
|
||||
else if (name === 'div' && attribs['class'] === 'novel-genres') {
|
||||
isParsingGenres = true;
|
||||
} else if (isParsingGenres && name === 'li') {
|
||||
isReadingGenre = true;
|
||||
} // summary
|
||||
else if (name === 'div' && attribs['class'] === 'novel-description') {
|
||||
isReadingSummary = true;
|
||||
} // status
|
||||
else if (name === 'div' && attribs['class'] === 'novel-tags') {
|
||||
isParsingInfo = true;
|
||||
} else if (isParsingInfo && name === 'li') {
|
||||
isReadingInfo = true;
|
||||
}
|
||||
// chapters
|
||||
else if (name === 'div' && attribs['class'] === 'col-md-6') {
|
||||
isParsingChapterList = true;
|
||||
} else if (isParsingChapterList && name === 'a') {
|
||||
isReadingChapter = true;
|
||||
tempChapter.chapterNumber = chapters.length + 1;
|
||||
tempChapter.path = novelPath + '/' + attribs['href'].split('/')[5];
|
||||
} else if (
|
||||
isReadingChapter &&
|
||||
name === 'i' &&
|
||||
attribs['class']?.includes('lock')
|
||||
) {
|
||||
isPaidChapter = true;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
// genres
|
||||
if (isParsingGenres) {
|
||||
if (isReadingGenre) {
|
||||
novel.genres += data + ', ';
|
||||
}
|
||||
} // summary
|
||||
else if (isReadingSummary) {
|
||||
novel.summary += data.trim();
|
||||
} // status
|
||||
else if (isParsingInfo) {
|
||||
if (isReadingInfo) {
|
||||
const detailName = data.toLowerCase().trim();
|
||||
switch (detailName) {
|
||||
case 'completed':
|
||||
novel.status = NovelStatus.Completed;
|
||||
break;
|
||||
case 'ongoing':
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
break;
|
||||
case 'hiatus':
|
||||
novel.status = NovelStatus.OnHiatus;
|
||||
break;
|
||||
default:
|
||||
novel.status = NovelStatus.Unknown;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// chapters
|
||||
else if (isParsingChapterList) {
|
||||
if (isReadingChapter) {
|
||||
tempChapter.name = (tempChapter.name || '') + data;
|
||||
}
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
// genres
|
||||
if (isParsingGenres) {
|
||||
if (isReadingGenre) {
|
||||
isReadingGenre = false; // stop reading genre
|
||||
} else {
|
||||
isParsingGenres = false; // stop parsing genres
|
||||
novel.genres = novel.genres?.slice(0, -2); // remove trailing comma
|
||||
}
|
||||
} // summary
|
||||
else if (isReadingSummary) {
|
||||
if (name === 'hr' || name === 'p') {
|
||||
novel.summary += '\n';
|
||||
} else if (name === 'div') {
|
||||
isReadingSummary = false;
|
||||
}
|
||||
} // status
|
||||
else if (isParsingInfo) {
|
||||
if (name === 'li') {
|
||||
isReadingInfo = false;
|
||||
} else if (name === 'div') {
|
||||
isParsingInfo = false;
|
||||
}
|
||||
} // chapters
|
||||
else if (isParsingChapterList) {
|
||||
if (isReadingChapter) {
|
||||
if (name === 'li') {
|
||||
if (isPaidChapter) {
|
||||
isPaidChapter = false;
|
||||
} else {
|
||||
tempChapter.name = tempChapter.name.trim();
|
||||
chapters.push(tempChapter);
|
||||
}
|
||||
isReadingChapter = false;
|
||||
tempChapter = {} as Plugin.ChapterItem;
|
||||
}
|
||||
} else if (name === 'ul') {
|
||||
isParsingChapterList = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
parser.write(body);
|
||||
parser.end();
|
||||
|
||||
novel.chapters = chapters;
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const res = await fetchApi(this.resolveUrl(chapterPath));
|
||||
const body = await res.text();
|
||||
const novelID = chapterPath.split('/')[0];
|
||||
const chapterID = body.match(/'chapter_id': '([\d]+)'/)?.[1];
|
||||
|
||||
if (!chapterID) throw new Error('No chapter found');
|
||||
|
||||
const { aux } = await fetchApi(this.site + '/aux_dem', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3',
|
||||
'Content-Type': 'application/json;charset=utf-8',
|
||||
},
|
||||
Referer: res.url,
|
||||
body: JSON.stringify({ 'x1': novelID, 'x2': chapterID }),
|
||||
}).then(res => res.json());
|
||||
|
||||
if (aux && typeof aux === 'string') {
|
||||
const base64 = aux.replace(/%R([a-f])&/g, (match, code) => {
|
||||
switch (code) {
|
||||
case 'a':
|
||||
return 'A';
|
||||
case 'c':
|
||||
return 'B';
|
||||
case 'b':
|
||||
return 'C';
|
||||
case 'd':
|
||||
return 'D';
|
||||
case 'f':
|
||||
return 'E';
|
||||
case 'e':
|
||||
return 'F';
|
||||
default:
|
||||
return match;
|
||||
}
|
||||
});
|
||||
|
||||
return decodeURIComponent(escape(decodeBase64(base64)));
|
||||
}
|
||||
|
||||
throw new Error('This chapter is closed');
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const body = await fetchApi(this.site + '/search', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3',
|
||||
'Content-Type': 'application/json;charset=utf-8',
|
||||
},
|
||||
Referer: this.site,
|
||||
body: JSON.stringify({ query: searchTerm }),
|
||||
}).then(res => res.text());
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
const items = body.match(/<a.*>([\s\S]*?)<\/a>/g) || [];
|
||||
items.forEach(elements => {
|
||||
const novelUrl = elements.match(/<a href="(.*?)"/)?.[1] || '';
|
||||
const path = novelUrl.split('/')[4];
|
||||
const name = elements.match(
|
||||
/<span class="ellipsis-1">(.*?)<\/span>/,
|
||||
)?.[1];
|
||||
|
||||
if (name && path) {
|
||||
const cover =
|
||||
elements.match(/<img src="(.*?)".*>/)?.[1] || defaultCover;
|
||||
novels.push({ name, cover, path });
|
||||
}
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
resolveUrl = (path: string, isNovel?: boolean) =>
|
||||
this.site + '/novel/' + path;
|
||||
|
||||
filters = {
|
||||
order: {
|
||||
value: 'popularity',
|
||||
label: 'Order by',
|
||||
options: [
|
||||
{ label: 'Popular Novels', value: 'popularity' },
|
||||
{ label: 'New Novels', value: 'newest' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new Foxteller();
|
||||
|
||||
const base64Characters =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
|
||||
|
||||
function decodeBase64(encodedString: string) {
|
||||
let output = '';
|
||||
let i = 0;
|
||||
|
||||
while (i < encodedString.length) {
|
||||
const enc1 = base64Characters.indexOf(encodedString.charAt(i++));
|
||||
const enc2 = base64Characters.indexOf(encodedString.charAt(i++));
|
||||
const enc3 = base64Characters.indexOf(encodedString.charAt(i++));
|
||||
const enc4 = base64Characters.indexOf(encodedString.charAt(i++));
|
||||
|
||||
const chr1 = (enc1 << 2) | (enc2 >> 4);
|
||||
output += String.fromCharCode(chr1);
|
||||
|
||||
if (enc3 !== 64) {
|
||||
const chr2 = ((enc2 & 15) << 4) | (enc3 >> 2);
|
||||
output += String.fromCharCode(chr2);
|
||||
}
|
||||
if (enc4 !== 64) {
|
||||
const chr3 = ((enc3 & 3) << 6) | enc4;
|
||||
output += String.fromCharCode(chr3);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import qs from 'qs';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
|
||||
class FreeWebNovel implements Plugin.PluginBase {
|
||||
id = 'FWN.com';
|
||||
name = 'Free Web Novel';
|
||||
site = 'https://freewebnovel.com';
|
||||
version = '1.0.1';
|
||||
icon = 'src/en/freewebnovel/icon.png';
|
||||
|
||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||
const r = await fetchApi(url);
|
||||
if (!r.ok)
|
||||
throw new Error(
|
||||
`Could not reach site (${r.status}: ${r.statusText}) try to open in webview.`,
|
||||
);
|
||||
return parseHTML(await r.text());
|
||||
}
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI): Plugin.NovelItem[] {
|
||||
return loadedCheerio('.li-row')
|
||||
.map((index, element) => ({
|
||||
name: loadedCheerio(element).find('.tit').text() || '',
|
||||
cover: loadedCheerio(element).find('img').attr('src'),
|
||||
path: loadedCheerio(element).find('h3 > a').attr('href') || '',
|
||||
}))
|
||||
.get()
|
||||
.filter(novel => novel.name && novel.path);
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let url = this.site;
|
||||
if (showLatestNovels) url += '/latest-release-novels/';
|
||||
else {
|
||||
if (filters && filters.genres && filters.genres.value !== 'all')
|
||||
url += filters.genres.value;
|
||||
else {
|
||||
url += '/most-popular-novels/';
|
||||
if (page != 1) return [];
|
||||
page = 0;
|
||||
}
|
||||
}
|
||||
url += page;
|
||||
|
||||
const $ = await this.getCheerio(url);
|
||||
return this.parseNovels($);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const loadedCheerio = await this.getCheerio(this.site + novelPath);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h1.tit').text(),
|
||||
cover: loadedCheerio('.pic > img').attr('src'),
|
||||
summary: loadedCheerio('.inner').text().trim(),
|
||||
};
|
||||
|
||||
novel.genres = loadedCheerio('[title=Genre]')
|
||||
.next()
|
||||
.text()
|
||||
.replace(/[\t\n]/g, '');
|
||||
|
||||
novel.author = loadedCheerio('[title=Author]')
|
||||
.next()
|
||||
.text()
|
||||
.replace(/[\t\n]/g, '');
|
||||
|
||||
novel.status = loadedCheerio('[title=Status]')
|
||||
.next()
|
||||
.text()
|
||||
.replace(/[\t\n]/g, '');
|
||||
|
||||
novel.genres = loadedCheerio('[title=Genre]')
|
||||
.next()
|
||||
.text()
|
||||
.trim()
|
||||
.replace(/[\t\n]/g, ',')
|
||||
.replace(/, /g, ',');
|
||||
|
||||
const chapters: Plugin.ChapterItem[] = loadedCheerio('#idData > li > a')
|
||||
.map((chapterIndex, element) => ({
|
||||
name:
|
||||
loadedCheerio(element).attr('title') ||
|
||||
'Chapter ' + (chapterIndex + 1),
|
||||
path:
|
||||
loadedCheerio(element).attr('href') ||
|
||||
novelPath.replace(
|
||||
'.html',
|
||||
'/chapter-' + (chapterIndex + 1) + '.html',
|
||||
),
|
||||
releaseTime: null,
|
||||
chapterNumber: chapterIndex + 1,
|
||||
}))
|
||||
.get();
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const loadedCheerio = await this.getCheerio(this.site + chapterPath);
|
||||
|
||||
// remove freewebnovel signature
|
||||
if (loadedCheerio('style').text().includes('p:nth-last-child(1)'))
|
||||
loadedCheerio('div.txt').find('p:last-child').remove();
|
||||
|
||||
const chapterText = loadedCheerio('div.txt').html() || '';
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const r = await fetchApi(this.site + '/search/', {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Referer: this.site,
|
||||
Origin: this.site,
|
||||
},
|
||||
method: 'POST',
|
||||
body: qs.stringify({ searchkey: searchTerm }),
|
||||
});
|
||||
if (!r.ok)
|
||||
throw new Error(
|
||||
'Could not reach site (' + r.status + ') try to open in webview.',
|
||||
);
|
||||
|
||||
const loadedCheerio = parseHTML(await r.text());
|
||||
const alertText =
|
||||
loadedCheerio('script')
|
||||
.text()
|
||||
.match(/alert\((.*?)\)/)?.[1] || '';
|
||||
if (alertText) throw new Error(alertText);
|
||||
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
genres: {
|
||||
type: FilterTypes.Picker,
|
||||
label: 'Genre',
|
||||
value: 'all',
|
||||
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-novel/Eastern' },
|
||||
{ label: 'Ecchi', value: '/genres/Ecchi/' },
|
||||
{ label: 'Fantasy', value: '/genres/Fantasy/' },
|
||||
{ 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: 'Game', value: '/genres/Game/' },
|
||||
{ label: 'Martial Arts', value: '/genres/Martial+Arts/' },
|
||||
{ label: 'Mature', value: '/genres/Mature/' },
|
||||
{ label: 'Mecha', value: '/genres/Mecha/' },
|
||||
{ label: 'Mystery', value: '/genres/Mystery/' },
|
||||
{ label: 'Psychological', value: '/genres/Psychological/' },
|
||||
{ label: 'Reincarnation', value: '/genres-novel/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 Ai', value: '/genres/Shounen+Ai/' },
|
||||
{ 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: 'Tragedy', value: '/genres/Tragedy/' },
|
||||
{ label: 'Wuxia', value: '/genres/Wuxia/' },
|
||||
{ label: 'Xianxia', value: '/genres/Xianxia/' },
|
||||
{ label: 'Xuanhuan', value: '/genres/Xuanhuan/' },
|
||||
{ label: 'Yaoi', value: '/genres/Yaoi/' },
|
||||
],
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new FreeWebNovel();
|
||||
@@ -0,0 +1,443 @@
|
||||
import { Parser } from 'htmlparser2';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class LightNovelPub implements Plugin.PagePlugin {
|
||||
id = 'lightnovelpub';
|
||||
name = 'LightNovelPub';
|
||||
version = '2.0.0';
|
||||
icon = 'src/en/lightnovelpub/icon.png';
|
||||
site = 'https://www.lightnovelpub.com/';
|
||||
headers = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
parseNovels(html: string) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
let tempNovel = {} as Plugin.NovelItem;
|
||||
let isParsingNovel = false;
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (isParsingNovel) {
|
||||
if (name === 'a') {
|
||||
tempNovel.path = attribs['href'].slice(1);
|
||||
tempNovel.name = attribs['title'];
|
||||
}
|
||||
if (name === 'img') {
|
||||
tempNovel.cover = attribs['data-src'] || attribs['src'];
|
||||
}
|
||||
if (tempNovel.path && tempNovel.cover) {
|
||||
novels.push(tempNovel);
|
||||
tempNovel = {} as Plugin.NovelItem;
|
||||
}
|
||||
}
|
||||
},
|
||||
onattribute(name, value) {
|
||||
if (name === 'class' && value === 'novel-item') {
|
||||
isParsingNovel = true;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'li') {
|
||||
isParsingNovel = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}browse/`;
|
||||
link += `${filters.genres.value}/`;
|
||||
link += `${filters.order.value}/`;
|
||||
link += `${filters.status.value}/`;
|
||||
link += page;
|
||||
|
||||
const body = await fetchApi(link).then(r => r.text());
|
||||
|
||||
return this.parseNovels(body);
|
||||
}
|
||||
|
||||
async parseNovel(
|
||||
novelPath: string,
|
||||
): Promise<Plugin.SourceNovel & { totalPages: number }> {
|
||||
const body = await fetchApi(this.site + novelPath).then(r => r.text());
|
||||
const novel: Plugin.SourceNovel & { totalPages: number } = {
|
||||
path: novelPath,
|
||||
name: '',
|
||||
summary: '',
|
||||
totalPages: 1,
|
||||
chapters: [],
|
||||
};
|
||||
let isHeaderStats = false;
|
||||
let isStatus = false;
|
||||
let isTotalChapters = false;
|
||||
let isNovelName = false;
|
||||
let isAuthorName = false;
|
||||
let isSummary = false;
|
||||
let isGenres = false;
|
||||
let isTags = false;
|
||||
let genreArray: string[] = [];
|
||||
let isCover = false;
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (name === 'h1' && attribs['class']?.includes('novel-title')) {
|
||||
isNovelName = true;
|
||||
}
|
||||
if (name === 'div' && attribs['class']?.includes('content')) {
|
||||
isSummary = true;
|
||||
}
|
||||
if (name === 'figure' && attribs['class'] === 'cover') {
|
||||
isCover = true;
|
||||
}
|
||||
if (isCover) {
|
||||
if (name === 'img') {
|
||||
novel.cover = attribs['data-src'] || attribs['src'];
|
||||
}
|
||||
}
|
||||
},
|
||||
onopentagname(name) {
|
||||
if (isHeaderStats && name === 'strong') {
|
||||
isStatus = true;
|
||||
}
|
||||
if (isSummary && name === 'br') {
|
||||
novel.summary += '\n';
|
||||
}
|
||||
if (isGenres && name === 'a') {
|
||||
isTags = true;
|
||||
}
|
||||
},
|
||||
onattribute(name, value) {
|
||||
if (name === 'class' && value === 'header-stats') {
|
||||
isTotalChapters = true;
|
||||
isHeaderStats = true;
|
||||
}
|
||||
if (name === 'itemprop' && value === 'author') {
|
||||
isAuthorName = true;
|
||||
}
|
||||
if (name === 'class' && value === 'categories') {
|
||||
isGenres = true;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isTotalChapters) {
|
||||
novel.totalPages = Math.ceil(parseInt(data, 10) / 100);
|
||||
}
|
||||
if (isStatus) {
|
||||
novel.status = data.trim();
|
||||
}
|
||||
if (isNovelName) {
|
||||
novel.name += data.trim();
|
||||
}
|
||||
if (isAuthorName) {
|
||||
novel.author = data;
|
||||
}
|
||||
if (isSummary) {
|
||||
novel.summary += data;
|
||||
}
|
||||
if (isTags) {
|
||||
genreArray.push(data);
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'strong') {
|
||||
isTotalChapters = false;
|
||||
isStatus = false;
|
||||
}
|
||||
if (name === 'i') {
|
||||
isStatus = false;
|
||||
}
|
||||
if (name === 'h1') {
|
||||
isNovelName = false;
|
||||
}
|
||||
if (name === 'span') {
|
||||
isAuthorName = false;
|
||||
}
|
||||
if (name === 'div') {
|
||||
isHeaderStats = false;
|
||||
isSummary = false;
|
||||
isGenres = false;
|
||||
}
|
||||
if (name === 'a') {
|
||||
isTags = false;
|
||||
}
|
||||
if (name === 'figure') {
|
||||
isCover = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(body);
|
||||
parser.end();
|
||||
novel.genres = genreArray.join(', ');
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
|
||||
const url = this.site + novelPath + '/chapters/page-' + page;
|
||||
const body = await fetchApi(url).then(res => res.text());
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
let tempChapter = {} as Plugin.ChapterItem;
|
||||
let isChapterItem = false;
|
||||
let isChapterList = false;
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (isChapterList) {
|
||||
if (name === 'li') {
|
||||
isChapterItem = true;
|
||||
tempChapter.chapterNumber = Number(attribs['data-orderno']);
|
||||
}
|
||||
if (isChapterItem) {
|
||||
if (name === 'a') {
|
||||
tempChapter.name = attribs['title'];
|
||||
tempChapter.path = attribs['href'].slice(1);
|
||||
}
|
||||
if (name === 'time') {
|
||||
tempChapter.releaseTime = dayjs(
|
||||
attribs['datetime'],
|
||||
).toISOString();
|
||||
}
|
||||
if (
|
||||
tempChapter.chapterNumber &&
|
||||
tempChapter.path &&
|
||||
tempChapter.releaseTime
|
||||
) {
|
||||
chapter.push(tempChapter);
|
||||
tempChapter = {} as Plugin.ChapterItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onattribute(name, value) {
|
||||
if (name === 'class' && value === 'chapter-list') {
|
||||
isChapterList = true;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'a') {
|
||||
isChapterItem = false;
|
||||
}
|
||||
if (name === 'ul') {
|
||||
isChapterList = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(body);
|
||||
parser.end();
|
||||
const chapters = chapter;
|
||||
return { chapters };
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchApi(this.site + chapterPath).then(r => r.text());
|
||||
|
||||
let chapterText = '';
|
||||
let isChapter = false;
|
||||
let isPtag = false;
|
||||
let isStyleText = false;
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (isChapter && name === 'div') {
|
||||
let stylediv = attribs['style'];
|
||||
if (stylediv) {
|
||||
chapterText += `<div style="${stylediv}">`;
|
||||
isStyleText = true;
|
||||
} else {
|
||||
chapterText += `<div>`;
|
||||
}
|
||||
}
|
||||
if (isChapter && name === 'table') {
|
||||
let w = attribs['width'];
|
||||
if (w) {
|
||||
chapterText += `<table width="${w}">`;
|
||||
} else {
|
||||
chapterText += `<table>`;
|
||||
}
|
||||
}
|
||||
if (isChapter && name === 'tbody') {
|
||||
chapterText += `<tbody>`;
|
||||
}
|
||||
if (isChapter && name === 'tr') {
|
||||
chapterText += `<tr>`;
|
||||
}
|
||||
if (isChapter && name === 'td') {
|
||||
let w1 = attribs['width'];
|
||||
if (w1) {
|
||||
chapterText += `<td width="${w1}">`;
|
||||
} else {
|
||||
chapterText += `<td>`;
|
||||
}
|
||||
}
|
||||
},
|
||||
onattribute(name, value) {
|
||||
if (name === 'id' && value === 'chapter-container') {
|
||||
isChapter = true;
|
||||
}
|
||||
if (name === 'class' && value?.includes('chapternav')) {
|
||||
isChapter = false;
|
||||
isPtag = false;
|
||||
}
|
||||
},
|
||||
onopentagname(name) {
|
||||
if (isChapter && name === 'p') {
|
||||
chapterText += '<p>';
|
||||
isPtag = true;
|
||||
if (isStyleText) {
|
||||
isStyleText = false;
|
||||
}
|
||||
}
|
||||
if (isChapter && name === 'br') {
|
||||
chapterText += `<br>`;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isPtag) {
|
||||
chapterText += data;
|
||||
}
|
||||
if (isStyleText) {
|
||||
chapterText += data;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'p') {
|
||||
isPtag = false;
|
||||
chapterText += '</p>';
|
||||
}
|
||||
if (isChapter && name === 'td') {
|
||||
chapterText += `</td>`;
|
||||
}
|
||||
if (isChapter && name === 'tr') {
|
||||
chapterText += `</tr>`;
|
||||
}
|
||||
if (isChapter && name === 'tbody') {
|
||||
chapterText += `</tbody>`;
|
||||
}
|
||||
if (isChapter && name === 'table') {
|
||||
chapterText += `</table>`;
|
||||
}
|
||||
if (isChapter && name === 'div') {
|
||||
isStyleText = false;
|
||||
chapterText += `</div>`;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(body);
|
||||
parser.end();
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}lnsearchlive`;
|
||||
const link = `${this.site}search`;
|
||||
const response = await fetchApi(link).then(r => r.text());
|
||||
let verifytoken = '';
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (
|
||||
name === 'input' &&
|
||||
attribs['name']?.includes('LNRequestVerifyToken')
|
||||
) {
|
||||
verifytoken = attribs['value'];
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(response);
|
||||
parser.end();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('inputContent', searchTerm);
|
||||
|
||||
const body = await fetchApi(url, {
|
||||
method: 'POST',
|
||||
headers: { LNRequestVerifyToken: verifytoken! },
|
||||
body: formData,
|
||||
}).then(r => r.json());
|
||||
|
||||
return this.parseNovels(body.resultview);
|
||||
}
|
||||
|
||||
filters = {
|
||||
order: {
|
||||
value: 'popular',
|
||||
label: 'Order by',
|
||||
options: [
|
||||
{ label: 'New', value: 'new' },
|
||||
{ label: 'Popular', value: 'popular' },
|
||||
{ label: 'Updates', value: 'updated' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
status: {
|
||||
value: 'all',
|
||||
label: 'Status',
|
||||
options: [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Completed', value: 'completed' },
|
||||
{ label: 'Ongoing', value: 'ongoing' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genres: {
|
||||
value: 'all',
|
||||
label: 'Genre',
|
||||
options: [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Action', value: 'action' },
|
||||
{ label: 'Adventure', value: 'adventure' },
|
||||
{ label: 'Drama', value: 'drama' },
|
||||
{ label: 'Fantasy', value: 'fantasy' },
|
||||
{ label: 'Harem', value: 'harem' },
|
||||
{ label: 'Martial Arts', value: 'martial-arts' },
|
||||
{ label: 'Mature', value: 'mature' },
|
||||
{ label: 'Romance', value: 'romance' },
|
||||
{ label: 'Tragedy', value: 'tragedy' },
|
||||
{ label: 'Xuanhuan', value: 'xuanhuan' },
|
||||
{ label: 'Ecchi', value: 'ecchi' },
|
||||
{ label: 'Comedy', value: 'comedy' },
|
||||
{ label: 'Slice of Life', value: 'slice-of-life' },
|
||||
{ label: 'Mystery', value: 'mystery' },
|
||||
{ label: 'Supernatural', value: 'supernatural' },
|
||||
{ label: 'Psychological', value: 'psychological' },
|
||||
{ label: 'Sci-fi', value: 'sci-fi' },
|
||||
{ label: 'Xianxia', value: 'xianxia' },
|
||||
{ label: 'School Life', value: 'school-life' },
|
||||
{ label: 'Josei', value: 'josei' },
|
||||
{ label: 'Wuxia', value: 'wuxia' },
|
||||
{ label: 'Shounen', value: 'shounen' },
|
||||
{ label: 'Horror', value: 'horror' },
|
||||
{ label: 'Mecha', value: 'mecha' },
|
||||
{ label: 'Historical', value: 'historical' },
|
||||
{ label: 'Shoujo', value: 'shoujo' },
|
||||
{ label: 'Adult', value: 'adult' },
|
||||
{ label: 'Seinen', value: 'seinen' },
|
||||
{ label: 'Sports', value: 'sports' },
|
||||
{ label: 'Lolicon', value: 'lolicon' },
|
||||
{ label: 'Gender Bender', value: 'gender-bender' },
|
||||
{ label: 'Shounen Ai', value: 'shounen-ai' },
|
||||
{ label: 'Yaoi', value: 'yaoi' },
|
||||
{ label: 'Video Games', value: 'video-games' },
|
||||
{ label: 'Smut', value: 'smut' },
|
||||
{ label: 'Magical Realism', value: 'magical-realism' },
|
||||
{ label: 'Eastern Fantasy', value: 'eastern-fantasy' },
|
||||
{ label: 'Contemporary Romance', value: 'contemporary-romance' },
|
||||
{ label: 'Fantasy Romance', value: 'fantasy-romance' },
|
||||
{ label: 'Shoujo Ai', value: 'shoujo-ai' },
|
||||
{ label: 'Yuri', value: 'yuri' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new LightNovelPub();
|
||||
@@ -0,0 +1,383 @@
|
||||
import { Parser } from 'htmlparser2';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
|
||||
class LnMTLPlugin implements Plugin.PagePlugin {
|
||||
id = 'lnmtl';
|
||||
name = 'LnMTL';
|
||||
icon = 'src/en/lnmtl/icon.png';
|
||||
site = 'https://lnmtl.com/';
|
||||
version = '2.0.0';
|
||||
|
||||
async sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = this.site + 'novel?';
|
||||
link += `orderBy=${filters.order.value}`;
|
||||
link += `&order=${filters.sort.value}`;
|
||||
link += `&filter=${filters.storyStatus.value}`;
|
||||
link += `&page=${page}`;
|
||||
|
||||
const body = await fetchApi(link);
|
||||
const html = await body.text();
|
||||
const baseUrl = this.site;
|
||||
let isParsingNovel = false;
|
||||
let tempNovel = {} as Plugin.NovelItem;
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (attribs['class']?.includes('media-left')) {
|
||||
isParsingNovel = true;
|
||||
}
|
||||
if (isParsingNovel) {
|
||||
switch (name) {
|
||||
case 'a':
|
||||
tempNovel.path = attribs['href'].slice(baseUrl.length);
|
||||
break;
|
||||
case 'img':
|
||||
tempNovel.name = attribs['alt'];
|
||||
tempNovel.cover = attribs['src'];
|
||||
break;
|
||||
}
|
||||
if (tempNovel.path && tempNovel.name) {
|
||||
novels.push(tempNovel);
|
||||
tempNovel = {} as Plugin.NovelItem;
|
||||
}
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'div') {
|
||||
isParsingNovel = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(
|
||||
novelPath: string,
|
||||
): Promise<Plugin.SourceNovel & { totalPages: number }> {
|
||||
const body = await fetchApi(this.site + novelPath);
|
||||
const html = await body.text().then(r => r.replace(/>\s+</g, '><'));
|
||||
|
||||
const novel: Plugin.SourceNovel & { totalPages: number } = {
|
||||
path: novelPath,
|
||||
name: '',
|
||||
totalPages: 1,
|
||||
summary: '',
|
||||
author: '',
|
||||
status: '',
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
let isScript = false;
|
||||
let isDescription = false;
|
||||
let isSource = false;
|
||||
let isPanelKey = false;
|
||||
let isPanelValue = 0;
|
||||
let isAuthorKey = false;
|
||||
let isStatusKey = false;
|
||||
let isList = 0;
|
||||
let isGenres = false;
|
||||
let genreArray: string[] = [];
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (name === 'img' && attribs['class']?.includes('img-rounded')) {
|
||||
novel.name = attribs['title'];
|
||||
novel.cover = attribs['src'];
|
||||
}
|
||||
if (name === 'ul' && attribs['class']?.includes('list-inline')) {
|
||||
isList++;
|
||||
}
|
||||
},
|
||||
onopentagname(name) {
|
||||
if (name === 'dt') {
|
||||
isPanelKey = true;
|
||||
}
|
||||
if (name === 'dd') {
|
||||
isPanelValue++;
|
||||
}
|
||||
if (isList === 1 && name === 'li') {
|
||||
isGenres = true;
|
||||
}
|
||||
},
|
||||
onattribute(name, value) {
|
||||
if (name === 'class' && value === 'description') {
|
||||
isDescription = true;
|
||||
}
|
||||
if (name === 'class' && value === 'source') {
|
||||
isDescription = false;
|
||||
isSource = true;
|
||||
}
|
||||
if (name === 'class' && value === 'progress') {
|
||||
isSource = false;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isScript) {
|
||||
const volume = JSON.parse(
|
||||
data.match(/lnmtl.volumes = (.+])(?=;)/)![1] || '',
|
||||
);
|
||||
novel.totalPages = volume.length;
|
||||
}
|
||||
if (isDescription) {
|
||||
novel.summary += data.trim() + '\n\n';
|
||||
}
|
||||
if (isSource) {
|
||||
novel.summary += data;
|
||||
}
|
||||
if (isPanelKey) {
|
||||
switch (data) {
|
||||
case 'Authors':
|
||||
isAuthorKey = true;
|
||||
break;
|
||||
case 'Current status':
|
||||
isStatusKey = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isAuthorKey && isPanelValue === 1) {
|
||||
novel.author += data.trim();
|
||||
isAuthorKey = false;
|
||||
}
|
||||
if (isStatusKey && isPanelValue === 2) {
|
||||
novel.status += data.trim();
|
||||
isStatusKey = false;
|
||||
}
|
||||
if (isGenres) {
|
||||
genreArray.push(data.trim());
|
||||
novel.genres = genreArray.join(', ');
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'ul') {
|
||||
isGenres = false;
|
||||
}
|
||||
if (name === 'main') {
|
||||
isPanelKey = false;
|
||||
isScript = true;
|
||||
}
|
||||
if (name === 'script') {
|
||||
isScript = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
|
||||
const result = await fetchApi(this.site + novelPath);
|
||||
const html = await result.text().then(r => r.replace(/>\s+</g, '><'));
|
||||
let isScript = false;
|
||||
let volume: VolumeEntry = {
|
||||
id: '',
|
||||
title: '',
|
||||
};
|
||||
const parser = new Parser({
|
||||
ontext(data) {
|
||||
if (isScript) {
|
||||
const volumes = JSON.parse(
|
||||
data.match(/lnmtl.volumes = (.+])(?=;)/)![1] || '',
|
||||
);
|
||||
volume = volumes[+page - 1];
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'main') {
|
||||
isScript = true;
|
||||
}
|
||||
if (name === 'script') {
|
||||
isScript = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
await this.sleep(1000);
|
||||
const volumeData = await fetchApi(
|
||||
`${this.site}chapter?volumeId=${volume.id}`,
|
||||
);
|
||||
const volumePage = await volumeData.json();
|
||||
const firstPage = volumePage.data.map((chapter: ChapterEntry) => ({
|
||||
name: `#${chapter.number} - ${chapter.title}`,
|
||||
path: `chapter/${chapter.slug}`,
|
||||
releaseTime: new Date(chapter.created_at).toISOString(), //converts time obtained to UTC +0, TODO: Make it not convert
|
||||
}));
|
||||
chapter.push(...firstPage);
|
||||
|
||||
for (let i = 2; i <= volumePage.last_page; i++) {
|
||||
await this.sleep(1000);
|
||||
const chapterData = await fetchApi(
|
||||
`${this.site}chapter?page=${i}&volumeId=${volume.id}`,
|
||||
);
|
||||
const chapterInfo = await chapterData.json();
|
||||
|
||||
const chapterDetails = chapterInfo.data.map((chapter: ChapterEntry) => ({
|
||||
name: `#${chapter.number} ${chapter.title}`,
|
||||
path: `chapter/${chapter.slug}`,
|
||||
releaseTime: new Date(chapter.created_at).toISOString(), //converts time obtained to UTC +0, TODO: Make it not convert
|
||||
}));
|
||||
|
||||
chapter.push(...chapterDetails);
|
||||
}
|
||||
const chapters = chapter;
|
||||
return { chapters };
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(this.site + chapterPath);
|
||||
const html = await result.text();
|
||||
let isText = false;
|
||||
let chapterText = '';
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (name === 'sentence' && attribs['class']?.includes('translated')) {
|
||||
isText = true;
|
||||
chapterText += '<p>';
|
||||
}
|
||||
},
|
||||
onopentagname(name) {
|
||||
if (name === 'nav') {
|
||||
isText = false;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isText) {
|
||||
chapterText += data;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'sentence') {
|
||||
chapterText += '</p>';
|
||||
isText = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
|
||||
return chapterText.replace(/„/g, '“');
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const html = await fetchApi(this.site)
|
||||
.then(b => b.text())
|
||||
.then(r => r.replace(/>\s+</g, '><'));
|
||||
let isScript = false;
|
||||
let isFooter = false;
|
||||
let list = '';
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (
|
||||
isFooter &&
|
||||
name === 'script' &&
|
||||
attribs['type']?.includes('application/javascript')
|
||||
) {
|
||||
isScript = true;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isScript) {
|
||||
list = data.match(/prefetch: '\/(.*json)/)![1];
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'footer') {
|
||||
isFooter = true;
|
||||
}
|
||||
if (name === 'script') {
|
||||
isScript = false;
|
||||
}
|
||||
if (name === 'body') {
|
||||
isFooter = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
|
||||
const search = await fetch(`${this.site}${list}`);
|
||||
const data = await search.json();
|
||||
|
||||
const nov = data.filter((res: { name: string }) =>
|
||||
res.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
nov.map((res: { name: string; slug: string; image: string }) => {
|
||||
const novelName = res.name;
|
||||
const novelUrl = `novel/${res.slug}`;
|
||||
const novelCover = res.image;
|
||||
|
||||
const novel = {
|
||||
path: novelUrl,
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
};
|
||||
novels.push(novel);
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
order: {
|
||||
value: 'favourites',
|
||||
label: 'Order by',
|
||||
options: [
|
||||
{ label: 'Favourites', value: 'favourites' },
|
||||
{ label: 'Name', value: 'name' },
|
||||
{ label: 'Addition Date', value: 'date' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
sort: {
|
||||
value: 'desc',
|
||||
label: 'Sort by',
|
||||
options: [
|
||||
{ label: 'Descending', value: 'desc' },
|
||||
{ label: 'Ascending', value: 'asc' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
storyStatus: {
|
||||
value: 'all',
|
||||
label: 'Status',
|
||||
options: [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Ongoing', value: 'ongoing' },
|
||||
{ label: 'Finished', value: 'finished' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new LnMTLPlugin();
|
||||
|
||||
interface ChapterEntry {
|
||||
number: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface VolumeEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
|
||||
class MTLNovel implements Plugin.PluginBase {
|
||||
id = 'mtlnovel';
|
||||
name = 'MTL Novel';
|
||||
version = '1.0.0';
|
||||
icon = 'src/en/mtlnovel/icon.png';
|
||||
site = 'https://www.mtlnovel.com/';
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}novel-list/?`;
|
||||
link += `orderby=${filters.order.value}`;
|
||||
link += `&order=${filters.sort.value}`;
|
||||
link += `&status=${filters.storyStatus.value}`;
|
||||
link += `&pg=${page}`;
|
||||
|
||||
const headers = new Headers();
|
||||
headers.append('Alt-Used', 'www.mtlnovel.com');
|
||||
const body = await fetch(link, { headers }).then(result => result.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('div.box.wide').each((i, el) => {
|
||||
const novelName = loadedCheerio(el).find('a.list-title').text().slice(4);
|
||||
const novelCover = loadedCheerio(el).find('amp-img').attr('src');
|
||||
const novelUrl = loadedCheerio(el).find('a.list-title').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const headers = new Headers();
|
||||
headers.append('Referer', `${this.site}novel-list/`);
|
||||
headers.append('Alt-Used', 'www.mtlnovel.com');
|
||||
|
||||
const body = await fetch(this.site + novelPath, {
|
||||
headers,
|
||||
}).then(result => result.text());
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h1.entry-title').text() || 'Untitled',
|
||||
cover: loadedCheerio('.nov-head > amp-img').attr('src'),
|
||||
summary: loadedCheerio('div.desc > h2').next().text().trim(),
|
||||
author: loadedCheerio('#author').text(),
|
||||
status: loadedCheerio('#status').text(),
|
||||
genres: loadedCheerio('#genre').text().replace(/\s+/g, ''),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
const chapterListUrl = this.site + novelPath + 'chapter-list/';
|
||||
|
||||
const getChapters = async () => {
|
||||
const listResult = await fetch(chapterListUrl, { headers });
|
||||
const listBody = await listResult.text();
|
||||
|
||||
loadedCheerio = parseHTML(listBody);
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('div.ch-list')
|
||||
.find('a.ch-link')
|
||||
.each((i, el) => {
|
||||
const chapterName = loadedCheerio(el).text().replace('~ ', '');
|
||||
const releaseDate = null;
|
||||
const chapterUrl = loadedCheerio(el).attr('href');
|
||||
if (!chapterUrl) return;
|
||||
chapter.push({
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
name: chapterName,
|
||||
releaseTime: releaseDate,
|
||||
});
|
||||
});
|
||||
return chapter.reverse();
|
||||
};
|
||||
|
||||
novel.chapters = await getChapters();
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const headers = new Headers();
|
||||
headers.append('Alt-Used', 'www.mtlnovel.com');
|
||||
const body = await fetch(this.site + chapterPath, { headers }).then(r =>
|
||||
r.text(),
|
||||
);
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const chapterText = loadedCheerio('div.par').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const headers = new Headers();
|
||||
headers.append('Alt-Used', 'www.mtlnovel.com');
|
||||
const searchUrl =
|
||||
this.site +
|
||||
'wp-admin/admin-ajax.php?action=autosuggest&q=' +
|
||||
searchTerm +
|
||||
'&__amp_source_origin=https%3A%2F%2Fwww.mtlnovel.com';
|
||||
|
||||
const res = await fetch(searchUrl, { headers });
|
||||
const result = await res.json();
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
interface SearchEntry {
|
||||
title: string;
|
||||
thumbnail: string;
|
||||
permalink: string;
|
||||
}
|
||||
result.items[0].results.map((item: SearchEntry) => {
|
||||
const novelName = item.title.replace(/<\/?strong>/g, '');
|
||||
const novelCover = item.thumbnail;
|
||||
const novelUrl = item.permalink.replace(this.site, '');
|
||||
|
||||
const novel = { name: novelName, cover: novelCover, path: novelUrl };
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
order: {
|
||||
value: 'view',
|
||||
label: 'Order by',
|
||||
options: [
|
||||
{ label: 'Date', value: 'date' },
|
||||
{ label: 'Name', value: 'name' },
|
||||
{ label: 'Rating', value: 'rating' },
|
||||
{ label: 'View', value: 'view' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
sort: {
|
||||
value: 'desc',
|
||||
label: 'Sort by',
|
||||
options: [
|
||||
{ label: 'Descending', value: 'desc' },
|
||||
{ label: 'Ascending', value: 'asc' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
storyStatus: {
|
||||
value: 'all',
|
||||
label: 'Status',
|
||||
options: [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Ongoing', value: 'ongoing' },
|
||||
{ label: 'Complete', value: 'completed' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
export default new MTLNovel();
|
||||
@@ -0,0 +1,107 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
|
||||
class MTLReader implements Plugin.PluginBase {
|
||||
id = 'mtlreader';
|
||||
name = 'MTL Reader';
|
||||
version = '1.0.0';
|
||||
icon = 'src/en/mtlreader/icon.png';
|
||||
site = 'https://mtlreader.com/';
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.col-md-4').each((i, el) => {
|
||||
const novelName = loadedCheerio(el).find('h5').text();
|
||||
const novelCover = loadedCheerio(el).find('img').attr('src');
|
||||
const novelUrl = loadedCheerio(el).find('h5 > a').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
options: Plugin.PopularNovelsOptions<Filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}novels?page=${pageNo}`;
|
||||
|
||||
const body = await fetchApi(url).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const body = await fetchApi(this.site + novelPath).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.agent-title').text().trim() || 'Untitled',
|
||||
cover: loadedCheerio('.agent-p-img > img').attr('src'),
|
||||
summary: loadedCheerio('#editdescription').text().trim(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
novel.author = loadedCheerio('i.fa-user')
|
||||
.parent()
|
||||
.text()
|
||||
.replace('Author: ', '')
|
||||
.trim();
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('tr.spaceUnder').each((i, el) => {
|
||||
const chapterName = loadedCheerio(el).find('a').text().trim();
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href');
|
||||
|
||||
if (!chapterUrl) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapter;
|
||||
|
||||
return novel;
|
||||
}
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchApi(this.site + chapterPath).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
loadedCheerio('.container ins,script,p.mtlreader').remove();
|
||||
const chapterText = loadedCheerio('.container').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const body = await fetchApi(this.site).then(r => r.text());
|
||||
const tokenCheerio = parseHTML(body);
|
||||
const token = tokenCheerio('input[name="_token"]').attr('value');
|
||||
|
||||
const searchUrl = `${this.site}search?_token=${token}&input=${searchTerm}`;
|
||||
const seacrhBody = await fetchApi(searchUrl).then(r => r.text());
|
||||
const loadedCheerio = parseHTML(seacrhBody);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
}
|
||||
export default new MTLReader();
|
||||
@@ -0,0 +1,237 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
|
||||
class NovelBin implements Plugin.PluginBase {
|
||||
id = 'novelbin';
|
||||
name = 'Novel Bin';
|
||||
icon = 'src/en/novelbin/icon.png';
|
||||
site = 'https://binnovel.com/';
|
||||
version = '1.0.1';
|
||||
imageRequestInit?: Plugin.ImageRequestInit | undefined = {
|
||||
headers: {
|
||||
'referrer': this.site,
|
||||
},
|
||||
};
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.col-novel-main .list-novel .row').each((i, el) => {
|
||||
const novelName = loadedCheerio(el).find('h3.novel-title > a').text();
|
||||
const novelCover = loadedCheerio(el)
|
||||
.find('img.cover')
|
||||
.attr('data-src')
|
||||
?.replace('_200_89', '');
|
||||
const novelUrl = loadedCheerio(el)
|
||||
.find('h3.novel-title > a')
|
||||
.attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
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 += `gen/${filters.genre.value}`;
|
||||
else link += `sort/${filters.sort.value}`;
|
||||
|
||||
if (filters.complete.value) link += `/completed`;
|
||||
|
||||
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 body = await fetchApi(this.site + novelPath).then(r => r.text());
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('div.book > img').attr('alt') || 'Untitled',
|
||||
cover: loadedCheerio('div.book > img').attr('data-src'),
|
||||
summary: loadedCheerio('div.desc-text').text().trim(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
loadedCheerio('ul.info > li > h3').each(function () {
|
||||
let detailName = loadedCheerio(this).text();
|
||||
let detail = loadedCheerio(this)
|
||||
.siblings()
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
switch (detailName) {
|
||||
case 'Author:':
|
||||
novel.author = detail;
|
||||
break;
|
||||
case 'Status:':
|
||||
novel.status = detail;
|
||||
break;
|
||||
case 'Genre:':
|
||||
novel.genres = detail;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const novelId = loadedCheerio('#rating').attr('data-novel-id');
|
||||
|
||||
const getChapters = async (id: string) => {
|
||||
const chapterListUrl = this.site + 'ajax/chapter-archive?novelId=' + id;
|
||||
|
||||
const data = await fetchApi(chapterListUrl);
|
||||
const chapterdata = await data.text();
|
||||
|
||||
loadedCheerio = parseHTML(chapterdata);
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('ul.list-chapter > li').each((i, el) => {
|
||||
const chapterName = loadedCheerio(el).find('a').attr('title');
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href');
|
||||
|
||||
if (!chapterName || !chapterUrl) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
});
|
||||
return chapter;
|
||||
};
|
||||
|
||||
if (novelId) {
|
||||
novel.chapters = await getChapters(novelId);
|
||||
}
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchApi(this.site + chapterPath).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
loadedCheerio('#chr-content > div,h6,p[style="display: none;"]').remove();
|
||||
const chapterText = loadedCheerio('#chr-content').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}search/?keyword=${searchTerm}`;
|
||||
const body = await fetchApi(url).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
value: 'popular-novels',
|
||||
label: 'Sort by',
|
||||
options: [
|
||||
{ label: 'Latest Release', value: 'update' },
|
||||
{ label: 'Hot Novel', value: 'hot-novels' },
|
||||
{ label: 'Completed Novel', value: 'c' },
|
||||
{ label: 'Most Popular', value: 'popular-novels' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genre: {
|
||||
value: '',
|
||||
label: 'Genres',
|
||||
options: [
|
||||
{ label: 'None', value: '' },
|
||||
{ label: 'Action', value: 'action' },
|
||||
{ label: 'Adult', value: 'adult' },
|
||||
{ label: 'Adventure', value: 'adventure' },
|
||||
{ label: 'Anime', value: 'anime' },
|
||||
{ label: 'Arts', value: 'arts' },
|
||||
{ label: 'Comedy', value: 'comedy' },
|
||||
{ label: 'Drama', value: 'drama' },
|
||||
{ label: 'Eastern', value: 'eastern' },
|
||||
{ label: 'Ecchi', value: 'ecchi' },
|
||||
{ label: 'Fan-fiction', value: 'fan-fiction' },
|
||||
{ label: 'Fanfiction', value: 'fanfiction' },
|
||||
{ label: 'Fantas', value: 'fantas' },
|
||||
{ label: 'Fantasy', value: 'fantasy' },
|
||||
{ label: 'Game', value: 'game' },
|
||||
{ label: 'Gender bender', value: 'gender-bender' },
|
||||
{ label: 'Harem', value: 'harem' },
|
||||
{ label: 'Hentai', value: 'hentai' },
|
||||
{ label: 'Historical', value: 'historical' },
|
||||
{ label: 'Horror', value: 'horror' },
|
||||
{ label: 'Isekai', value: 'isekai' },
|
||||
{ label: 'Josei', value: 'josei' },
|
||||
{ label: 'Lgbt+', value: 'lgbt+' },
|
||||
{ label: 'Litrpg', value: 'litrpg' },
|
||||
{ label: 'Magic', value: 'magic' },
|
||||
{ label: 'Magical realism', value: 'magical-realism' },
|
||||
{ label: 'Manhua', value: 'manhua' },
|
||||
{ label: 'Martial arts', value: 'martial-arts' },
|
||||
{ label: 'Mature', value: 'mature' },
|
||||
{ label: 'Mecha', value: 'mecha' },
|
||||
{ label: 'Military', value: 'military' },
|
||||
{ label: 'Modern life', value: 'modern-life' },
|
||||
{ label: 'Movies', value: 'movies' },
|
||||
{ label: 'Mystery', value: 'mystery' },
|
||||
{ label: 'Portal fantasy', value: 'portal-fantasy' },
|
||||
{ label: 'Psychological', value: 'psychological' },
|
||||
{ label: 'Realistic fiction', value: 'realistic-fiction' },
|
||||
{ label: 'Reincarnation', value: 'reincarnation' },
|
||||
{ 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: 'Shoujo ai', value: 'shoujo-ai' },
|
||||
{ label: 'Shounen', value: 'shounen' },
|
||||
{ label: 'Shounen ai', value: 'shounen-ai' },
|
||||
{ label: 'Slice of life', value: 'slice-of-life' },
|
||||
{ label: 'Smut', value: 'smut' },
|
||||
{ label: 'Sports', value: 'sports' },
|
||||
{ label: 'Supernatural', value: 'supernatural' },
|
||||
{ label: 'System', value: 'system' },
|
||||
{ label: 'Tragedy', value: 'tragedy' },
|
||||
{ label: 'Urban life', value: 'urban-life' },
|
||||
{ label: 'Video games', value: 'video-games' },
|
||||
{ label: 'Wuxia', value: 'wuxia' },
|
||||
{ label: 'Xianxia', value: 'xianxia' },
|
||||
{ label: 'Xuanhuan', value: 'xuanhuan' },
|
||||
{ label: 'Yaoi', value: 'yaoi' },
|
||||
{ label: 'Yuri', value: 'yuri' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
complete: {
|
||||
label: 'Show Completed Novels Only',
|
||||
value: false,
|
||||
type: FilterTypes.Switch,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new NovelBin();
|
||||
@@ -0,0 +1,174 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
|
||||
class NovelBuddy implements Plugin.PluginBase {
|
||||
id = 'novelbuddy';
|
||||
name = 'NovelBuddy.io';
|
||||
site = 'https://novelbuddy.io/';
|
||||
version = '1.0.0';
|
||||
icon = 'src/en/novelbuddy/icon.png';
|
||||
filters?: undefined; //TODO: Filters, Filters in Filters required
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.book-item').each((idx, ele) => {
|
||||
const novelName = loadedCheerio(ele).find('.title').text();
|
||||
const novelCover =
|
||||
'https:' + loadedCheerio(ele).find('img').attr('data-src');
|
||||
const novelUrl = loadedCheerio(ele)
|
||||
.find('.title a')
|
||||
.attr('href')
|
||||
?.substring(1);
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = { name: novelName, cover: novelCover, path: novelUrl };
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
options: Plugin.PopularNovelsOptions,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}popular?page=${pageNo}`;
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.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);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.name h1').text().trim() || 'Untitled',
|
||||
cover: 'https:' + loadedCheerio('.img-cover img').attr('data-src'),
|
||||
summary: loadedCheerio('.section-body.summary').text().trim(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
loadedCheerio('.meta.box p').each((i, el) => {
|
||||
const detailName = loadedCheerio(el).find('strong').text();
|
||||
const detail = loadedCheerio(el).find('a');
|
||||
|
||||
switch (detailName) {
|
||||
case 'Authors :':
|
||||
novel.author = detail
|
||||
.find('span')
|
||||
.map((a, ex) => loadedCheerio(ex).text())
|
||||
.toArray()
|
||||
.join(', ');
|
||||
break;
|
||||
case 'Status :':
|
||||
novel.status = detail.text();
|
||||
break;
|
||||
case 'Genres :':
|
||||
novel.genres = detail.text().trim();
|
||||
break;
|
||||
}
|
||||
});
|
||||
const novelId = loadedCheerio('script')
|
||||
.text()
|
||||
.match(/bookId = (\d+);/)![1];
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
const getChapters = async (id: string) => {
|
||||
const chapterListUrl = `${this.site}api/manga/${id}/chapters?source=detail`;
|
||||
const data = await fetchApi(chapterListUrl);
|
||||
const chapterlist = await data.text();
|
||||
|
||||
loadedCheerio = parseHTML(chapterlist);
|
||||
|
||||
loadedCheerio('li').each((i, el) => {
|
||||
const chapterName = loadedCheerio(el)
|
||||
.find('.chapter-title')
|
||||
.text()
|
||||
.trim();
|
||||
|
||||
const releaseDate = loadedCheerio(el)
|
||||
.find('.chapter-update')
|
||||
.text()
|
||||
.trim();
|
||||
|
||||
const months = [
|
||||
'jan',
|
||||
'feb',
|
||||
'mar',
|
||||
'apr',
|
||||
'may',
|
||||
'jun',
|
||||
'jul',
|
||||
'aug',
|
||||
'sep',
|
||||
'oct',
|
||||
'nov',
|
||||
'dec',
|
||||
].join('|');
|
||||
const rx = new RegExp(`(${months}) (\\d{1,2}), (\\d{4})`, 'i').exec(
|
||||
releaseDate,
|
||||
);
|
||||
if (!rx) return;
|
||||
const year = +rx[3];
|
||||
const month = months.indexOf(rx[1].toLowerCase());
|
||||
const day = +rx[2];
|
||||
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href')?.slice(1);
|
||||
|
||||
if (!chapterUrl) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
releaseTime: new Date(year, month, day).toISOString(),
|
||||
path: chapterUrl,
|
||||
});
|
||||
});
|
||||
return chapter;
|
||||
};
|
||||
|
||||
novel.chapters = (await getChapters(novelId)).reverse();
|
||||
|
||||
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('#listen-chapter').remove();
|
||||
loadedCheerio('#google_translate_element').remove();
|
||||
|
||||
const chapterText = loadedCheerio('.chapter__content').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}search?q=${searchTerm}&page=${page}`;
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
}
|
||||
|
||||
export default new NovelBuddy();
|
||||
@@ -0,0 +1,197 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } 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.0';
|
||||
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();
|
||||
|
||||
const novelCover =
|
||||
this.site + loadedCheerio(ele).find('img').attr('src')?.slice(1);
|
||||
|
||||
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);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('div.book > img').attr('alt') || 'Untitled',
|
||||
cover: this.site + loadedCheerio('div.book > img').attr('src'),
|
||||
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;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const searchUrl = `${this.site}search?keyword=${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();
|
||||
@@ -0,0 +1,131 @@
|
||||
import { load, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
|
||||
class NovelHall implements Plugin.PluginBase {
|
||||
id = 'novelhall';
|
||||
name = 'Novel Hall';
|
||||
version = '1.0.1';
|
||||
icon = 'src/en/novelhall/icon.png';
|
||||
filters?: Filters | undefined; //TODO: Filters Requires hideOnSelect
|
||||
site = 'https://novelhall.com/';
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
options: Plugin.PopularNovelsOptions<Filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}all2022-${page}.html`;
|
||||
|
||||
const body = await fetchApi(url).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('li.btm').each((idx, ele) => {
|
||||
const novelName = loadedCheerio(ele).text().trim();
|
||||
const novelUrl = loadedCheerio(ele).find('a').attr('href');
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: defaultCover,
|
||||
path: novelUrl,
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const body = await fetchApi(this.site + novelPath).then(r => r.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.book-info > h1').text() || 'Untitled',
|
||||
cover: loadedCheerio('meta[property="og:image"]').attr('content'),
|
||||
summary: loadedCheerio('.intro').text().trim(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
loadedCheerio('.total').find('p').remove();
|
||||
novel.author = loadedCheerio('.total span:contains("Author")')
|
||||
.text()
|
||||
.replace('Author:', '')
|
||||
.trim();
|
||||
|
||||
novel.status = loadedCheerio('.total span:contains("Status")')
|
||||
.text()
|
||||
.replace('Status:', '')
|
||||
.replace('Active', 'Ongoing')
|
||||
.trim();
|
||||
|
||||
novel.genres = loadedCheerio('.total a')
|
||||
.map((a, ex) => loadedCheerio(ex).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('#morelist ul > li').each((idx, ele) => {
|
||||
const chapterName = loadedCheerio(ele).find('a').text().trim();
|
||||
const chapterUrl = loadedCheerio(ele).find('a').attr('href');
|
||||
if (!chapterUrl) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl,
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapter;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchApi(this.site + chapterPath).then(r => r.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const chapterText = loadedCheerio('.content').html() || '';
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}index.php?s=so&module=book&keyword=${searchTerm}`;
|
||||
const body = await fetchApi(url).then(r => r.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('table tr').each((idx, ele) => {
|
||||
const novelName = loadedCheerio(ele)
|
||||
.find('td:nth-child(2)')
|
||||
.text()
|
||||
.replace(/\t+/g, '')
|
||||
.replace(/\n/g, ' ');
|
||||
const novelUrl = loadedCheerio(ele)
|
||||
.find('td:nth-child(2) a')
|
||||
.attr('href');
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: defaultCover,
|
||||
path: novelUrl,
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
export default new NovelHall();
|
||||
@@ -0,0 +1,835 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
|
||||
class NovelUpdates implements Plugin.PluginBase {
|
||||
id = 'novelupdates';
|
||||
name = 'Novel Updates';
|
||||
version = '0.5.4';
|
||||
icon = 'src/en/novelupdates/icon.png';
|
||||
site = 'https://www.novelupdates.com/';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('div.search_main_box_nu').each((idx, ele) => {
|
||||
const novelCover = loadedCheerio(ele).find('img').attr('src');
|
||||
const novelName = loadedCheerio(ele).find('.search_title > a').text();
|
||||
const novelUrl = loadedCheerio(ele)
|
||||
.find('.search_title > a')
|
||||
.attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
|
||||
novels.push(novel);
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}`;
|
||||
if (
|
||||
filters.language.value.length ||
|
||||
filters.novelType.value.length ||
|
||||
filters.genres.value.include?.length ||
|
||||
filters.genres.value.exclude?.length ||
|
||||
filters.storyStatus.value !== ''
|
||||
) {
|
||||
link += 'series-finder/?sf=1';
|
||||
} else if (showLatestNovels) {
|
||||
link += 'latest-series/?st=1';
|
||||
} else {
|
||||
link += 'series-ranking/?rank=week';
|
||||
}
|
||||
|
||||
if (filters.language.value.length)
|
||||
link += '&org=' + filters.language.value.join(',');
|
||||
|
||||
if (filters.novelType.value.length)
|
||||
link += '&nt=' + filters.novelType.value.join(',');
|
||||
|
||||
if (filters.genres.value.include?.length)
|
||||
link += '&gi=' + filters.genres.value.include.join(',');
|
||||
|
||||
if (filters.genres.value.exclude?.length)
|
||||
link += '&ge=' + filters.genres.value.exclude.join(',');
|
||||
|
||||
if (
|
||||
filters.genres.value.include?.length ||
|
||||
filters.genres.value.exclude?.length
|
||||
)
|
||||
link += '&mgi=' + filters.genre_operator.value;
|
||||
|
||||
if (filters.storyStatus.value.length)
|
||||
link += '&ss=' + filters.storyStatus.value;
|
||||
|
||||
link += '&sort=' + filters.sort.value;
|
||||
|
||||
link += '&order=' + filters.order.value;
|
||||
|
||||
link += '&pg=' + page;
|
||||
|
||||
const body = await fetchApi(link).then(result => result.text());
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const url = this.site + novelPath;
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
let loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.seriestitlenu').text() || 'Untitled',
|
||||
cover: loadedCheerio('.wpb_wrapper img').attr('src'),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
novel.author = loadedCheerio('#authtag')
|
||||
.map((i, el) => loadedCheerio(el).text().trim())
|
||||
.toArray()
|
||||
.join(', ');
|
||||
|
||||
novel.genres = loadedCheerio('#seriesgenre')
|
||||
.children('a')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
novel.status = loadedCheerio('#editstatus').text().includes('Ongoing')
|
||||
? 'Ongoing'
|
||||
: 'Completed';
|
||||
|
||||
const type = loadedCheerio('#showtype').text().trim();
|
||||
|
||||
const summary = loadedCheerio('#editdescription').text().trim();
|
||||
|
||||
novel.summary = summary + `\n\nType: ${type}`;
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
const novelId = loadedCheerio('input#mypostid').attr('value')!;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('action', 'nd_getchapters');
|
||||
formData.append('mygrr', '0');
|
||||
formData.append('mypostid', novelId);
|
||||
|
||||
const link = `${this.site}wp-admin/admin-ajax.php`;
|
||||
|
||||
const text = await fetchApi(link, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
}).then(data => data.text());
|
||||
|
||||
loadedCheerio = parseHTML(text);
|
||||
|
||||
const nameReplacements: { [key: string]: string } = {
|
||||
'v': 'volume ',
|
||||
'c': ' chapter ',
|
||||
'part': 'part ',
|
||||
'ss': 'SS',
|
||||
};
|
||||
|
||||
loadedCheerio('li.sp_li_chp').each((i, el) => {
|
||||
let chapterName = loadedCheerio(el).text();
|
||||
for (let name in nameReplacements) {
|
||||
chapterName = chapterName.replace(name, nameReplacements[name]);
|
||||
}
|
||||
chapterName = chapterName.replace(/\b\w/g, l => l.toUpperCase()).trim();
|
||||
const chapterUrl =
|
||||
'https:' + loadedCheerio(el).find('a').first().next().attr('href');
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapter.reverse();
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
getLocation(href: string) {
|
||||
var match = href.match(
|
||||
/^(https?:)\/\/(([^:/?#]*)(?::([0-9]+))?)([/]{0,1}[^?#]*)(\?[^#]*|)(#.*|)$/,
|
||||
);
|
||||
return match && `${match[1]}//${match[3]}`;
|
||||
}
|
||||
|
||||
async getChapterBody(
|
||||
loadedCheerio: CheerioAPI,
|
||||
domain: string[],
|
||||
url: string,
|
||||
result: Response,
|
||||
) {
|
||||
let bloatClasses = [];
|
||||
let chapterTitle = '';
|
||||
let chapterContent = '';
|
||||
let chapterText = '';
|
||||
|
||||
const unwanted = ['blogspot', 'wordpress', 'www'];
|
||||
const targetDomain = domain.find(d => !unwanted.includes(d));
|
||||
|
||||
switch (targetDomain) {
|
||||
case 'anotivereads':
|
||||
chapterTitle = loadedCheerio('#comic-nav-name').first().text()!;
|
||||
chapterContent = loadedCheerio('#spliced-comic').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'asuratls':
|
||||
let titleElementAsura = loadedCheerio('.post-body div b').first();
|
||||
chapterTitle = titleElementAsura.text()!;
|
||||
titleElementAsura.remove();
|
||||
chapterContent = loadedCheerio('.post-body').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'helscans':
|
||||
chapterTitle = loadedCheerio('.entry-title-main').first().text()!;
|
||||
const chapterStringHelScans =
|
||||
'Chapter ' + chapterTitle.split('Chapter')[1].trim();
|
||||
loadedCheerio('#readerarea.rdminimal')
|
||||
.children()
|
||||
.each((idx, ele) => {
|
||||
let elementText = loadedCheerio(ele).text();
|
||||
if (elementText.includes(chapterStringHelScans)) {
|
||||
chapterTitle = elementText;
|
||||
loadedCheerio(ele).remove();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
chapterContent = loadedCheerio('#readerarea.rdminimal').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'hiraethtranslation':
|
||||
chapterTitle = loadedCheerio('li.active').first().text()!;
|
||||
chapterContent = loadedCheerio('.text-left').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'hostednovel':
|
||||
chapterTitle = loadedCheerio('#chapter-title').first().text()!;
|
||||
chapterContent = loadedCheerio('#chapter-content').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'inoveltranslation':
|
||||
const link_inovel = 'https://api.' + url.slice(8);
|
||||
const json_inovel = await fetchApi(link_inovel).then(r => r.json());
|
||||
chapterTitle =
|
||||
'Chapter ' + json_inovel.chapter + ' | ' + json_inovel.title;
|
||||
chapterContent = json_inovel.content.replace(/\n/g, '<br>');
|
||||
if (json_inovel.notes) {
|
||||
chapterContent +=
|
||||
'<br><hr><br>TL Notes:<br>' +
|
||||
json_inovel.notes.replace(/\n/g, '<br>');
|
||||
}
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'ko-fi':
|
||||
chapterText = loadedCheerio('script:contains("shadowDom.innerHTML")')
|
||||
.html()
|
||||
?.match(/shadowDom\.innerHTML \+= '(<div.*?)';/)![1]!;
|
||||
break;
|
||||
case 'mirilu':
|
||||
bloatClasses = ['#jp-post-flair'];
|
||||
bloatClasses.map(tag => loadedCheerio(tag).remove());
|
||||
let titleElementMirilu = loadedCheerio(
|
||||
'.entry-content p strong',
|
||||
).first();
|
||||
chapterTitle = titleElementMirilu.text()!;
|
||||
titleElementMirilu.remove();
|
||||
chapterContent = loadedCheerio('.entry-content').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'novelplex':
|
||||
bloatClasses = ['.passingthrough_adreminder'];
|
||||
bloatClasses.map(tag => loadedCheerio(tag).remove());
|
||||
chapterTitle = loadedCheerio('.halChap--jud').first().text()!;
|
||||
chapterContent = loadedCheerio('.halChap--kontenInner ').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'novelworldtranslations':
|
||||
bloatClasses = ['.separator img'];
|
||||
bloatClasses.map(tag => loadedCheerio(tag).remove());
|
||||
loadedCheerio('.entry-content a')
|
||||
.filter((_, el) => {
|
||||
return (
|
||||
loadedCheerio(el)
|
||||
.attr('href')
|
||||
?.includes('https://novelworldtranslations.blogspot.com') ||
|
||||
false
|
||||
);
|
||||
})
|
||||
.each((_, el) => {
|
||||
loadedCheerio(el).parent().remove();
|
||||
});
|
||||
chapterTitle = loadedCheerio('.entry-title').first().text()!;
|
||||
chapterContent = loadedCheerio('.entry-content')
|
||||
.html()!
|
||||
.replace(/ /g, '')
|
||||
.replace(/\n/g, '<br>');
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'raeitranslations':
|
||||
const parts = url.split('/');
|
||||
const link_raei = `${parts[0]}//api.${parts[2]}/api/chapters/?id=${parts[3]}&num=${parts[4]}`;
|
||||
const json = await fetchApi(link_raei).then(r => r.json());
|
||||
chapterTitle = 'Chapter ' + json.currentChapter.num;
|
||||
chapterContent =
|
||||
json.currentChapter.head +
|
||||
`<br><hr><br>` +
|
||||
json.currentChapter.body +
|
||||
`<br><hr><br>Translator's Note:<br>` +
|
||||
json.currentChapter.note;
|
||||
chapterContent = chapterContent.replace(/\n/g, '<br>');
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'rainofsnow':
|
||||
let displayedDivSnow = loadedCheerio('.bb-item').filter(function () {
|
||||
return loadedCheerio(this).css('display') === 'block';
|
||||
});
|
||||
let loadedCheerioSnow = parseHTML(displayedDivSnow.html()!);
|
||||
bloatClasses = [
|
||||
'.responsivevoice-button',
|
||||
'.zoomdesc-cont p img',
|
||||
'.zoomdesc-cont p noscript',
|
||||
];
|
||||
bloatClasses.map(tag => loadedCheerioSnow(tag).remove());
|
||||
chapterContent = loadedCheerioSnow('.zoomdesc-cont').html()!;
|
||||
let titleElementSnow = loadedCheerioSnow('.scroller h2').first();
|
||||
if (titleElementSnow.length) {
|
||||
chapterTitle = titleElementSnow.text()!;
|
||||
titleElementSnow.remove();
|
||||
chapterContent = loadedCheerioSnow('.zoomdesc-cont').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
} else if (chapterContent) {
|
||||
chapterText = chapterContent;
|
||||
}
|
||||
break;
|
||||
case 'sacredtexttranslations':
|
||||
bloatClasses = [
|
||||
'.entry-content blockquote',
|
||||
'.entry-content div',
|
||||
'.reaction-buttons',
|
||||
];
|
||||
bloatClasses.map(tag => loadedCheerio(tag).remove());
|
||||
chapterTitle = loadedCheerio('.entry-title').first().text()!;
|
||||
chapterContent = loadedCheerio('.entry-content').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'scribblehub':
|
||||
chapterTitle = loadedCheerio('.chapter-title').first().text()!;
|
||||
chapterContent = loadedCheerio('div.chp_raw').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'skydemonorder':
|
||||
/**
|
||||
* Check for age verification
|
||||
*/
|
||||
const ageVerification = loadedCheerio('main').text().toLowerCase()!;
|
||||
if (ageVerification.includes('age verification required')) {
|
||||
throw new Error('Age verification required, please open in webview.');
|
||||
}
|
||||
chapterTitle = `${loadedCheerio('.pl-4 h1').first().text()!} | ${loadedCheerio('.pl-4 div').first().text()!}`;
|
||||
chapterContent = loadedCheerio('#startContainer + * > *')
|
||||
.first()
|
||||
.html()!;
|
||||
if (!chapterContent) {
|
||||
chapterContent = `${loadedCheerio('#chapter-body').html()!}<hr><br>There could be missing content, please check in webview.`;
|
||||
}
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'stabbingwithasyringe':
|
||||
/**
|
||||
* Get the chapter link from the main page
|
||||
*/
|
||||
const linkSyringe = loadedCheerio('.entry-content a').attr('href')!;
|
||||
const resultSyringe = await fetchApi(linkSyringe);
|
||||
const bodySyringe = await resultSyringe.text();
|
||||
const loadedCheerioSyringe = parseHTML(bodySyringe);
|
||||
bloatClasses = ['.wp-block-buttons', '#jp-post-flair', '.wpcnt'];
|
||||
bloatClasses.map(tag => loadedCheerioSyringe(tag).remove());
|
||||
chapterContent = loadedCheerioSyringe('.entry-content').html()!;
|
||||
let titleElementSyringe =
|
||||
loadedCheerioSyringe('.entry-content h3').first();
|
||||
if (titleElementSyringe.length) {
|
||||
chapterTitle = titleElementSyringe.text();
|
||||
titleElementSyringe.remove();
|
||||
chapterContent = loadedCheerioSyringe('.entry-content').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
} else if (chapterContent) {
|
||||
chapterText = chapterContent;
|
||||
}
|
||||
break;
|
||||
case 'tinytranslation':
|
||||
bloatClasses = [
|
||||
'.content noscript',
|
||||
'.google_translate_element',
|
||||
'.navigate',
|
||||
'.post-views',
|
||||
'br',
|
||||
];
|
||||
bloatClasses.map(tag => loadedCheerio(tag).remove());
|
||||
chapterTitle = loadedCheerio('.title-content').first().text()!;
|
||||
loadedCheerio('.title-content').first().remove();
|
||||
chapterContent = loadedCheerio('.content').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'tumblr':
|
||||
chapterText = loadedCheerio('.post').html()!;
|
||||
break;
|
||||
case 'wattpad':
|
||||
chapterTitle = loadedCheerio('.h2').first().text()!;
|
||||
chapterContent = loadedCheerio('.part-content pre').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'webnovel':
|
||||
chapterTitle = loadedCheerio('.cha-tit .pr .dib').first().text()!;
|
||||
chapterContent = loadedCheerio('.cha-words').html()!;
|
||||
if (!chapterContent) {
|
||||
chapterContent = loadedCheerio('._content').html()!;
|
||||
}
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'wuxiaworld':
|
||||
bloatClasses = ['.MuiLink-root'];
|
||||
bloatClasses.map(tag => loadedCheerio(tag).remove());
|
||||
chapterTitle = loadedCheerio('h4 span').first().text()!;
|
||||
chapterContent = loadedCheerio('.chapter-content').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
case 'zetrotranslation':
|
||||
bloatClasses = ['hr', 'p:contains("\u00a0")'];
|
||||
bloatClasses.map(tag => loadedCheerio(tag).remove());
|
||||
chapterContent = loadedCheerio('.text-left').html()!;
|
||||
let titleElementZetro = loadedCheerio('.text-left h2').first();
|
||||
if (titleElementZetro.length) {
|
||||
chapterTitle = titleElementZetro.text()!;
|
||||
titleElementZetro.remove();
|
||||
chapterContent = loadedCheerio('.text-left').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
} else if (chapterContent) {
|
||||
chapterTitle = loadedCheerio('.active').first().text()!;
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
let bloatClasses = [];
|
||||
let chapterTitle = '';
|
||||
let chapterContent = '';
|
||||
let chapterText = '';
|
||||
|
||||
const result = await fetchApi(this.site + chapterPath);
|
||||
const body = await result.text();
|
||||
const url = result.url.toLowerCase();
|
||||
const domain = url.split('/')[2].split('.');
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
/**
|
||||
* Check for Captcha
|
||||
*/
|
||||
const title = loadedCheerio('title').text().toLowerCase().trim();
|
||||
if (
|
||||
title == 'bot verification' ||
|
||||
title == 'just a moment...' ||
|
||||
title == 'redirecting...' ||
|
||||
title == 'un instant...' ||
|
||||
title == 'you are being redirected...'
|
||||
) {
|
||||
throw new Error('Captcha error, please open in webview.');
|
||||
}
|
||||
if (!result.ok) {
|
||||
/**
|
||||
* Check if the chapter url is wrong or the site is genuinely down
|
||||
*/
|
||||
throw new Error(
|
||||
`Could not reach site (${result.status}), try to open in webview.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the site is a Blogspot site
|
||||
*/
|
||||
let isBlogspotStr = loadedCheerio(
|
||||
'meta[name="google-adsense-platform-domain"]',
|
||||
).attr('content');
|
||||
let isBlogspot = false;
|
||||
if (isBlogspotStr) {
|
||||
isBlogspot = isBlogspotStr.toLowerCase().includes('blogspot');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the site is a WordPress site
|
||||
*/
|
||||
let isWordPressStr =
|
||||
loadedCheerio('#dcl_comments-js-extra').html() ||
|
||||
loadedCheerio('meta[name="generator"]').attr('content') ||
|
||||
loadedCheerio('footer').text()!;
|
||||
let isWordPress = false;
|
||||
if (isWordPressStr) {
|
||||
isWordPress =
|
||||
isWordPressStr.toLowerCase().includes('wordpress') ||
|
||||
isWordPressStr.includes('Site Kit by Google') ||
|
||||
loadedCheerio('.powered-by').text().toLowerCase().includes('wordpress');
|
||||
}
|
||||
|
||||
/**
|
||||
* In case sites are not detected correctly
|
||||
*/
|
||||
const manualWordPress = ['genesistls'];
|
||||
if (!isWordPress && domain.find(wp => manualWordPress.includes(wp))) {
|
||||
isWordPress = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sites that are WordPress or Blogspot but have different structure
|
||||
*/
|
||||
const outliers = [
|
||||
'anotivereads',
|
||||
'asuratls',
|
||||
'helscans',
|
||||
'mirilu',
|
||||
'novelworldtranslations',
|
||||
'sacredtexttranslations',
|
||||
'stabbingwithasyringe',
|
||||
'tinytranslation',
|
||||
'zetrotranslation',
|
||||
];
|
||||
if (domain.find(d => outliers.includes(d))) {
|
||||
isWordPress = false;
|
||||
isBlogspot = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blogspot sites:
|
||||
* - ¼-Assed
|
||||
* - AsuraTls (Outlier)
|
||||
* - Novel World Translations (Outlier)
|
||||
* - SacredText TL (Outlier)
|
||||
*
|
||||
* WordPress sites:
|
||||
* - Anomlaously Creative (Outlier)
|
||||
* - Arcane Translations
|
||||
* - Blossom Translation
|
||||
* - Dumahs Translations
|
||||
* - ElloMTL
|
||||
* - Gem Novels
|
||||
* - Genesis Translations (Manually added)
|
||||
* - Goblinslate
|
||||
* - Hel Scans (Outlier)
|
||||
* - ippotranslations
|
||||
* - JATranslations
|
||||
* - Light Novels Translations
|
||||
* - Mirilu - Novel Reader Attempts Translating (Outlier)
|
||||
* - Neosekai Translations
|
||||
* - Shanghai Fantasy
|
||||
* - Soafp
|
||||
* - Stabbing with a Syringe (Outlier)
|
||||
* - StoneScape
|
||||
* - TinyTL (Outlier)
|
||||
* - Wonder Novels
|
||||
* - Yong Library
|
||||
* - Zetro Translation (Outlier)
|
||||
*/
|
||||
if (!isWordPress && !isBlogspot) {
|
||||
chapterText = await this.getChapterBody(
|
||||
loadedCheerio,
|
||||
domain,
|
||||
url,
|
||||
result,
|
||||
);
|
||||
} else if (isBlogspot) {
|
||||
bloatClasses = ['.button-container', '.separator'];
|
||||
bloatClasses.map(tag => loadedCheerio(tag).remove());
|
||||
chapterTitle =
|
||||
loadedCheerio('.entry-title').first().text() ||
|
||||
loadedCheerio('.post-title').first().text()!;
|
||||
chapterContent =
|
||||
loadedCheerio('.entry-content').html() ||
|
||||
loadedCheerio('.post-body').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
} else if (isWordPress) {
|
||||
bloatClasses = [
|
||||
'.ad',
|
||||
'.author-avatar',
|
||||
'.chapter-warning',
|
||||
'.entry-meta',
|
||||
'.ezoic-ad',
|
||||
'.genesistls-watermark',
|
||||
'.mb-center',
|
||||
'.modern-footnotes-footnote__note',
|
||||
'.patreon-widget',
|
||||
'.post-cats',
|
||||
'.pre-bar',
|
||||
'.sharedaddy',
|
||||
'.sidebar',
|
||||
'.wp-block-buttons',
|
||||
'.wp-block-image',
|
||||
'.wp-dark-mode-switcher',
|
||||
'.wp-next-post-navi',
|
||||
'#jp-post-flair',
|
||||
'#textbox',
|
||||
];
|
||||
bloatClasses.map(tag => loadedCheerio(tag).remove());
|
||||
chapterTitle =
|
||||
loadedCheerio('.entry-title').first().text() ||
|
||||
loadedCheerio('.entry-title-main').first().text() ||
|
||||
loadedCheerio('.chapter__title').first().text() ||
|
||||
loadedCheerio('.sp-title').first().text() ||
|
||||
loadedCheerio('.title-content').first().text() ||
|
||||
loadedCheerio('.wp-block-post-title').first().text() ||
|
||||
loadedCheerio('.title_story').first().text() ||
|
||||
loadedCheerio('.active').first().text() ||
|
||||
loadedCheerio('head title').first().text()!;
|
||||
let chapterSubtitle = loadedCheerio('.cat-series').first().text() || '';
|
||||
if (chapterSubtitle) {
|
||||
chapterTitle = chapterSubtitle;
|
||||
}
|
||||
chapterContent =
|
||||
loadedCheerio('.rdminimal').html() ||
|
||||
loadedCheerio('.entry-content').html() ||
|
||||
loadedCheerio('.chapter__content').html() ||
|
||||
loadedCheerio('.prevent-select').html() ||
|
||||
loadedCheerio('.text_story').html() ||
|
||||
loadedCheerio('.contenta').html() ||
|
||||
loadedCheerio('.single_post').html() ||
|
||||
loadedCheerio('.post-entry').html() ||
|
||||
loadedCheerio('.main-content').html() ||
|
||||
loadedCheerio('.content').html() ||
|
||||
loadedCheerio('.page-body').html() ||
|
||||
loadedCheerio('.td-page-content').html() ||
|
||||
loadedCheerio('#content').html() ||
|
||||
loadedCheerio('article.post').html()!;
|
||||
if (chapterTitle && chapterContent) {
|
||||
chapterText = `<h2>${chapterTitle}</h2><hr><br>${chapterContent}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!chapterText) {
|
||||
/**
|
||||
* Remove unnecessary tags
|
||||
*/
|
||||
const tags = ['nav', 'header', 'footer', '.hidden'];
|
||||
tags.map(tag => loadedCheerio(tag).remove());
|
||||
chapterText = loadedCheerio('body').html()!;
|
||||
}
|
||||
|
||||
if (chapterText) {
|
||||
/**
|
||||
* Convert relative urls to absolute
|
||||
*/
|
||||
chapterText = chapterText.replace(
|
||||
/href="\//g,
|
||||
`href="${this.getLocation(result.url)}/`,
|
||||
);
|
||||
}
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
/**
|
||||
* Split searchTerm by specific special characters and find the longest split
|
||||
*/
|
||||
const splits = searchTerm.split('*');
|
||||
const longestSearchTerm = splits.reduce(
|
||||
(a, b) => (a.length > b.length ? a : b),
|
||||
'',
|
||||
);
|
||||
searchTerm = longestSearchTerm.replace(/[‘’]/g, "'").replace(/\s+/g, '+');
|
||||
|
||||
const url = this.site + '?s=' + searchTerm + '&post_type=seriesplans';
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Sort Results By',
|
||||
value: 'sdate',
|
||||
options: [
|
||||
{ label: 'Last Updated', value: 'sdate' },
|
||||
{ label: 'Rating', value: 'srate' },
|
||||
{ label: 'Rank', value: 'srank' },
|
||||
{ label: 'Reviews', value: 'sreview' },
|
||||
{ label: 'Chapters', value: 'srel' },
|
||||
{ label: 'Title', value: 'abc' },
|
||||
{ label: 'Readers', value: 'sread' },
|
||||
{ label: 'Frequency', value: 'sfrel' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
order: {
|
||||
label: 'Order',
|
||||
value: 'desc',
|
||||
options: [
|
||||
{ label: 'Descending', value: 'desc' },
|
||||
{ label: 'Ascending', value: 'asc' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
storyStatus: {
|
||||
label: 'Story Status (Translation)',
|
||||
value: '',
|
||||
options: [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Completed', value: '2' },
|
||||
{ label: 'Ongoing', value: '3' },
|
||||
{ label: 'Hiatus', value: '4' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
language: {
|
||||
label: 'Language',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: 'Chinese', value: '495' },
|
||||
{ label: 'Filipino', value: '9181' },
|
||||
{ label: 'Indonesian', value: '9179' },
|
||||
{ label: 'Japanese', value: '496' },
|
||||
{ label: 'Khmer', value: '18657' },
|
||||
{ label: 'Korean', value: '497' },
|
||||
{ label: 'Malaysian', value: '9183' },
|
||||
{ label: 'Thai', value: '9954' },
|
||||
{ label: 'Vietnamese', value: '9177' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
novelType: {
|
||||
label: 'Novel Type',
|
||||
value: [],
|
||||
options: [
|
||||
{ label: 'Light Novel', value: '2443' },
|
||||
{ label: 'Published Novel', value: '26874' },
|
||||
{ label: 'Web Novel', value: '2444' },
|
||||
],
|
||||
type: FilterTypes.CheckboxGroup,
|
||||
},
|
||||
genres: {
|
||||
label: 'Genres',
|
||||
type: FilterTypes.ExcludableCheckboxGroup,
|
||||
value: {
|
||||
include: [],
|
||||
exclude: [],
|
||||
},
|
||||
options: [
|
||||
{ label: 'Action', value: '8' },
|
||||
{ label: 'Adult', value: '280' },
|
||||
{ label: 'Adventure', value: '13' },
|
||||
{ label: 'Comedy', value: '17' },
|
||||
{ label: 'Drama', value: '9' },
|
||||
{ label: 'Ecchi', value: '292' },
|
||||
{ label: 'Fantasy', value: '5' },
|
||||
{ label: 'Gender Bender', value: '168' },
|
||||
{ label: 'Harem', value: '3' },
|
||||
{ label: 'Historical', value: '330' },
|
||||
{ label: 'Horror', value: '343' },
|
||||
{ label: 'Josei', value: '324' },
|
||||
{ label: 'Martial Arts', value: '14' },
|
||||
{ label: 'Mature', value: '4' },
|
||||
{ label: 'Mecha', value: '10' },
|
||||
{ label: 'Mystery', value: '245' },
|
||||
{ label: 'Psychoical', value: '486' },
|
||||
{ label: 'Romance', value: '15' },
|
||||
{ label: 'School Life', value: '6' },
|
||||
{ label: 'Sci-fi', value: '11' },
|
||||
{ label: 'Seinen', value: '18' },
|
||||
{ label: 'Shoujo', value: '157' },
|
||||
{ label: 'Shoujo Ai', value: '851' },
|
||||
{ label: 'Shounen', value: '12' },
|
||||
{ label: 'Shounen Ai', value: '1692' },
|
||||
{ label: 'Slice of Life', value: '7' },
|
||||
{ label: 'Smut', value: '281' },
|
||||
{ label: 'Sports', value: '1357' },
|
||||
{ label: 'Supernatural', value: '16' },
|
||||
{ label: 'Tragedy', value: '132' },
|
||||
{ label: 'Wuxia', value: '479' },
|
||||
{ label: 'Xianxia', value: '480' },
|
||||
{ label: 'Xuanhuan', value: '3954' },
|
||||
{ label: 'Yaoi', value: '560' },
|
||||
{ label: 'Yuri', value: '922' },
|
||||
],
|
||||
},
|
||||
genre_operator: {
|
||||
label: 'Genre (AND/OR)',
|
||||
value: 'and',
|
||||
options: [
|
||||
{ label: 'AND', value: 'and' },
|
||||
{ label: 'OR', value: 'or' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new NovelUpdates();
|
||||
@@ -0,0 +1,226 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
|
||||
class PawRead implements Plugin.PluginBase {
|
||||
id = 'pawread';
|
||||
name = 'PawRead';
|
||||
version = '2.0.0';
|
||||
icon = 'src/en/pawread/icon.png';
|
||||
site = 'https://m.pawread.com/';
|
||||
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.list-comic, .itemBox').each((idx, ele) => {
|
||||
loadedCheerio(ele).find('.serialise').remove();
|
||||
const novelName = loadedCheerio(ele).find('a').text().trim();
|
||||
const novelCover = loadedCheerio(ele).find('img').attr('src');
|
||||
const novelUrl = loadedCheerio(ele).find('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<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}list/`;
|
||||
|
||||
const filterValues = [
|
||||
filters.status.value,
|
||||
filters.lang.value,
|
||||
filters.genre.value,
|
||||
];
|
||||
|
||||
link += filterValues.filter(value => value !== '').join('-');
|
||||
if (filterValues.some(value => value !== '')) link += '/';
|
||||
|
||||
if (filters.order.value) link += '-';
|
||||
|
||||
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();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('#Cover img').attr('title') || 'Untitled',
|
||||
cover: loadedCheerio('#Cover img').attr('src'),
|
||||
author: loadedCheerio('.icon01 <').text().trim(),
|
||||
status: loadedCheerio('.txtItme:first').text().trim(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
novel.summary = loadedCheerio('#full-des')
|
||||
.find('br')
|
||||
.replaceWith('\n')
|
||||
.end()
|
||||
.text()
|
||||
.trim();
|
||||
|
||||
novel.genres = loadedCheerio('a.btn-default')
|
||||
.map((i, el) => loadedCheerio(el).text().trim())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
loadedCheerio('.item-box').each((idx, ele) => {
|
||||
const smallText = loadedCheerio(ele).find('span:last').text().trim();
|
||||
if (smallText === 'Advanced Chapter') return;
|
||||
const releaseDate = smallText.split('.').map(x => Number(x));
|
||||
const chapterName = loadedCheerio(ele).find('span:first').text().trim();
|
||||
const chapterUrl = loadedCheerio(ele).attr('onclick')?.match(/\d+/)![0];
|
||||
if (!chapterUrl) return;
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
path: `${novelPath}${chapterUrl}.html`,
|
||||
releaseTime: new Date(
|
||||
releaseDate[0],
|
||||
releaseDate[1],
|
||||
releaseDate[2],
|
||||
).toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapter;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(this.site + chapterPath);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const steal = ['bit.ly', 'tinyurl', 'pawread'];
|
||||
steal.map(tag => loadedCheerio(`p:icontains(${tag})`).remove());
|
||||
const chapterText = loadedCheerio('.main').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const searchUrl = `${this.site}search/?keywords=${searchTerm}&page=${page}`;
|
||||
|
||||
const result = await fetchApi(searchUrl);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
status: {
|
||||
value: '',
|
||||
label: 'Status',
|
||||
options: [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Completed', value: 'wanjie' },
|
||||
{ label: 'Ongoing', value: 'lianzai' },
|
||||
{ label: 'Hiatus', value: 'hiatus' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
lang: {
|
||||
value: '',
|
||||
label: 'Languages',
|
||||
options: [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Chinese', value: 'chinese' },
|
||||
{ label: 'Korean', value: 'korean' },
|
||||
{ label: 'Japanese', value: 'japanese' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genre: {
|
||||
value: '',
|
||||
label: 'Genres',
|
||||
options: [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Fantasy', value: 'Fantasy' },
|
||||
{ label: 'Action', value: 'Action' },
|
||||
{ label: 'Xuanhuan', value: 'Xuanhuan' },
|
||||
{ label: 'Romance', value: 'Romance' },
|
||||
{ label: 'Comedy', value: 'Comedy' },
|
||||
{ label: 'Mystery', value: 'Mystery' },
|
||||
{ label: 'Mature', value: 'Mature' },
|
||||
{ label: 'Harem', value: 'Harem' },
|
||||
{ label: 'Wuxia', value: 'Wuxia' },
|
||||
{ label: 'Xianxia', value: 'Xianxia' },
|
||||
{ label: 'Tragedy', value: 'Tragedy' },
|
||||
{ label: 'Sci-fi', value: 'Scifi' },
|
||||
{ label: 'Historical', value: 'Historical' },
|
||||
{ label: 'Ecchi', value: 'Ecchi' },
|
||||
{ label: 'Adventure', value: 'Adventure' },
|
||||
{ label: 'Adult', value: 'Adult' },
|
||||
{ label: 'Supernatural', value: 'Supernatural' },
|
||||
{ label: 'Psychological', value: 'Psychological' },
|
||||
{ label: 'Drama', value: 'Drama' },
|
||||
{ label: 'Horror', value: 'Horror' },
|
||||
{ label: 'Josei', value: 'Josei' },
|
||||
{ label: 'Mecha', value: 'Mecha' },
|
||||
{ label: 'Seinen', value: 'Seinen' },
|
||||
{ label: 'Shoujo', value: 'Shoujo' },
|
||||
{ label: 'Shounen', value: 'Shounen' },
|
||||
{ label: 'Smut', value: 'Smut' },
|
||||
{ label: 'Yaoi', value: 'Yaoi' },
|
||||
{ label: 'Yuri', value: 'Yuri' },
|
||||
{ label: 'Martial Arts', value: 'MartialArts' },
|
||||
{ label: 'School Life', value: 'SchoolLife' },
|
||||
{ label: 'Shoujo Ai', value: 'ShoujoAi' },
|
||||
{ label: 'Shounen Ai', value: 'ShounenAi' },
|
||||
{ label: 'Slice of Life', value: 'SliceofLife' },
|
||||
{ label: 'Gender Bender', value: 'GenderBender' },
|
||||
{ label: 'Sports', value: 'Sports' },
|
||||
{ label: 'Urban', value: 'Urban' },
|
||||
{ label: 'Adventurer', value: 'Adventurer' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
sort: {
|
||||
value: 'click',
|
||||
label: 'Sort By',
|
||||
options: [
|
||||
{ label: 'Time Updated', value: 'update' },
|
||||
{ label: 'Time Posted', value: 'post' },
|
||||
{ label: 'Clicks', value: 'click' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
order: {
|
||||
value: false,
|
||||
label: 'Order ↑ ↓',
|
||||
type: FilterTypes.Switch,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new PawRead();
|
||||
@@ -0,0 +1,385 @@
|
||||
import { Parser } from 'htmlparser2';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
|
||||
class RanobesPlugin implements Plugin.PagePlugin {
|
||||
id = 'ranobes';
|
||||
name = 'Ranobes';
|
||||
icon = 'src/en/ranobes/icon.png';
|
||||
site = 'https://ranobes.top';
|
||||
filters?: Filters | undefined;
|
||||
version = '2.0.0';
|
||||
|
||||
async sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
parseNovels(html: string) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
let tempNovel = {} as Plugin.NovelItem;
|
||||
tempNovel.name = '';
|
||||
const baseUrl = this.site;
|
||||
let isParsingNovel = false;
|
||||
let isTitleTag = false;
|
||||
let isNovelName = false;
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (attribs['class']?.includes('short-cont')) {
|
||||
isParsingNovel = true;
|
||||
}
|
||||
if (isParsingNovel) {
|
||||
if (name === 'h2' && attribs['class']?.includes('title')) {
|
||||
isTitleTag = true;
|
||||
}
|
||||
if (isTitleTag && name === 'a') {
|
||||
tempNovel.path = attribs['href'].slice(baseUrl.length);
|
||||
isNovelName = true;
|
||||
}
|
||||
if (name === 'figure') {
|
||||
tempNovel.cover = attribs['style'].replace(
|
||||
/.*url\((.*?)\)./g,
|
||||
'$1',
|
||||
);
|
||||
}
|
||||
if (tempNovel.path && tempNovel.cover) {
|
||||
novels.push(tempNovel);
|
||||
tempNovel = {} as Plugin.NovelItem;
|
||||
tempNovel.name = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isNovelName) {
|
||||
tempNovel.name += data;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'h2') {
|
||||
isNovelName = false;
|
||||
isTitleTag = false;
|
||||
}
|
||||
if (name === 'figure') {
|
||||
isParsingNovel = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
return novels;
|
||||
}
|
||||
|
||||
parseChapters(data: { chapters: ChapterEntry[] }) {
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
data.chapters.map((item: ChapterEntry) => {
|
||||
chapter.push({
|
||||
name: item.title,
|
||||
releaseTime: new Date(item.date).toISOString(),
|
||||
path: item.link.slice(this.site.length),
|
||||
});
|
||||
});
|
||||
return chapter.reverse();
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}/novels/page/${page}/`;
|
||||
const body = await fetchApi(link).then(r => r.text());
|
||||
|
||||
return this.parseNovels(body);
|
||||
}
|
||||
|
||||
async parseNovel(
|
||||
novelPath: string,
|
||||
): Promise<Plugin.SourceNovel & { totalPages: number }> {
|
||||
const url = this.site + novelPath;
|
||||
const result = await fetchApi(url);
|
||||
const html = await result.text();
|
||||
const novel: Plugin.SourceNovel & { totalPages: number } = {
|
||||
path: novelPath,
|
||||
name: '',
|
||||
summary: '',
|
||||
chapters: [],
|
||||
totalPages: 1,
|
||||
};
|
||||
let baseUrl = this.site;
|
||||
let isCover = false;
|
||||
let isAuthor = false;
|
||||
let isSummary = false;
|
||||
let isStatus = false;
|
||||
let isStatusText = false;
|
||||
let isGenres = false;
|
||||
let isGenresText = false;
|
||||
let genreArray: string[] = [];
|
||||
let novelId = '';
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (attribs['class'] === 'poster') {
|
||||
isCover = true;
|
||||
}
|
||||
if (isCover && name === 'img') {
|
||||
novel.name = attribs['alt'];
|
||||
novel.cover = baseUrl + attribs['src'];
|
||||
}
|
||||
if (name === 'div' && attribs['class'] === 'moreless') {
|
||||
isSummary = true;
|
||||
}
|
||||
if (name === 'li' && attribs['title']?.includes('Original status')) {
|
||||
isStatus = true;
|
||||
}
|
||||
if (name === 'input' && attribs['name'] === 'newsid') {
|
||||
novelId = attribs['value'];
|
||||
}
|
||||
},
|
||||
onopentagname(name) {
|
||||
if (isSummary && name === 'br') {
|
||||
novel.summary += `/n`;
|
||||
}
|
||||
if (isStatus && name === 'a') {
|
||||
isStatusText = true;
|
||||
}
|
||||
if (isGenres && name === 'a') {
|
||||
isGenresText = true;
|
||||
}
|
||||
},
|
||||
onattribute(name, value) {
|
||||
if (name === 'itemprop' && value === 'creator') {
|
||||
isAuthor = true;
|
||||
}
|
||||
if (name === 'id' && value === 'mc-fs-genre') {
|
||||
isGenres = true;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isAuthor) {
|
||||
novel.author = data;
|
||||
}
|
||||
if (isSummary) {
|
||||
novel.summary += data;
|
||||
}
|
||||
if (isStatusText) {
|
||||
novel.status = data === 'Ongoing' ? 'Ongoing' : 'Completed';
|
||||
}
|
||||
if (isGenresText) {
|
||||
genreArray.push(data);
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'a') {
|
||||
isCover = false;
|
||||
isAuthor = false;
|
||||
isStatusText = false;
|
||||
isGenresText = false;
|
||||
isStatus = false;
|
||||
}
|
||||
if (name === 'div') {
|
||||
isSummary = false;
|
||||
isGenres = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
novel.genres = genreArray.join(', ');
|
||||
|
||||
const chapterListUrl = this.site + '/chapters/' + novelId + '/';
|
||||
const chaptersHtml = await fetchApi(chapterListUrl).then(r => r.text());
|
||||
let dataJson: {
|
||||
pages_count: string;
|
||||
chapters: ChapterEntry[];
|
||||
} = { pages_count: '', chapters: [] };
|
||||
let isScript = false;
|
||||
const parser2 = new Parser({
|
||||
ontext(data) {
|
||||
if (isScript) {
|
||||
if (data.includes('window.__DATA__ =')) {
|
||||
dataJson = JSON.parse(data.replace('window.__DATA__ =', ''));
|
||||
}
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'main') {
|
||||
isScript = true;
|
||||
}
|
||||
if (name === 'script') {
|
||||
isScript = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser2.write(chaptersHtml);
|
||||
parser2.end();
|
||||
|
||||
novel.totalPages = Number(dataJson.pages_count);
|
||||
novel.chapters = this.parseChapters(dataJson);
|
||||
|
||||
const latestChapterUrl = dataJson.chapters[0].link;
|
||||
const latestChapterName = dataJson.chapters[0].title;
|
||||
const latestChapterDate = dataJson.chapters[0].date;
|
||||
|
||||
novel.latestChapter = latestChapterUrl
|
||||
? {
|
||||
path: latestChapterUrl.replace(this.site, ''),
|
||||
name: latestChapterName,
|
||||
releaseTime: new Date(latestChapterDate).toISOString(),
|
||||
}
|
||||
: undefined;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
|
||||
const pagePath = novelPath.split('-')[0];
|
||||
const firstUrl = this.site + '/chapters' + pagePath.replace('novels/', '');
|
||||
const pageUrl = firstUrl + '/page/' + page;
|
||||
const pageBody = await fetchApi(pageUrl).then(r => r.text());
|
||||
let isScript = false;
|
||||
let dataJson: {
|
||||
pages_count: string;
|
||||
chapters: ChapterEntry[];
|
||||
} = { pages_count: '', chapters: [] };
|
||||
const parser = new Parser({
|
||||
ontext(data) {
|
||||
if (isScript) {
|
||||
if (data.includes('window.__DATA__ =')) {
|
||||
dataJson = JSON.parse(data.replace('window.__DATA__ =', ''));
|
||||
}
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'main') {
|
||||
isScript = true;
|
||||
}
|
||||
if (name === 'script') {
|
||||
isScript = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(pageBody);
|
||||
parser.end();
|
||||
const chapters = this.parseChapters(dataJson);
|
||||
return {
|
||||
chapters,
|
||||
};
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(this.site + chapterPath);
|
||||
const html = await result.text();
|
||||
let chapterText = '';
|
||||
let isChapter = false;
|
||||
let isPtag = false;
|
||||
let isStyleText = false;
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (isChapter && name === 'div') {
|
||||
let stylediv = attribs['style'];
|
||||
if (stylediv) {
|
||||
chapterText += `<div style="${stylediv}">`;
|
||||
isStyleText = true;
|
||||
} else {
|
||||
chapterText += `<div>`;
|
||||
}
|
||||
}
|
||||
if (isChapter && name === 'table') {
|
||||
let w = attribs['width'];
|
||||
if (w) {
|
||||
chapterText += `<table width="${w}">`;
|
||||
} else {
|
||||
chapterText += `<table>`;
|
||||
}
|
||||
}
|
||||
if (isChapter && name === 'tbody') {
|
||||
chapterText += `<tbody>`;
|
||||
}
|
||||
if (isChapter && name === 'tr') {
|
||||
chapterText += `<tr>`;
|
||||
}
|
||||
if (isChapter && name === 'td') {
|
||||
let w1 = attribs['width'];
|
||||
if (w1) {
|
||||
chapterText += `<td width="${w1}">`;
|
||||
} else {
|
||||
chapterText += `<td>`;
|
||||
}
|
||||
}
|
||||
},
|
||||
onattribute(name, value) {
|
||||
if (name === 'id' && value === 'arrticle') {
|
||||
isChapter = true;
|
||||
}
|
||||
if (name === 'class' && value === 'category grey ellipses') {
|
||||
isChapter = false;
|
||||
isPtag = false;
|
||||
}
|
||||
},
|
||||
onopentagname(name) {
|
||||
if (isChapter && name === 'p') {
|
||||
chapterText += '<p>';
|
||||
isPtag = true;
|
||||
if (isStyleText) {
|
||||
isStyleText = false;
|
||||
}
|
||||
}
|
||||
if (isChapter && name === 'br') {
|
||||
chapterText += `<br>`;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isPtag) {
|
||||
chapterText += data;
|
||||
}
|
||||
if (isStyleText) {
|
||||
chapterText += data;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'p') {
|
||||
isPtag = false;
|
||||
chapterText += '</p>';
|
||||
}
|
||||
if (isChapter && name === 'td') {
|
||||
chapterText += `</td>`;
|
||||
}
|
||||
if (isChapter && name === 'tr') {
|
||||
chapterText += `</tr>`;
|
||||
}
|
||||
if (isChapter && name === 'tbody') {
|
||||
chapterText += `</tbody>`;
|
||||
}
|
||||
if (isChapter && name === 'table') {
|
||||
chapterText += `</table>`;
|
||||
}
|
||||
if (isChapter && name === 'div') {
|
||||
isStyleText = false;
|
||||
chapterText += `</div>`;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}/search/${searchTerm}/page/${page}`;
|
||||
const body = await fetchApi(link).then(r => r.text());
|
||||
|
||||
return this.parseNovels(body);
|
||||
}
|
||||
}
|
||||
export default new RanobesPlugin();
|
||||
|
||||
interface ChapterEntry {
|
||||
id: number;
|
||||
title: string;
|
||||
date: string;
|
||||
link: string;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { isUrlAbsolute } from '@libs/isAbsoluteUrl';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class ReadLiteNovel implements Plugin.PluginBase {
|
||||
id = 'rln.app';
|
||||
name = 'ReadLiteNovel';
|
||||
version = '1.0.0';
|
||||
icon = 'src/en/readlitenovel/icon.png';
|
||||
site = 'https://rln.app';
|
||||
|
||||
parseAgoDate(date: string | undefined) {
|
||||
//parseMadaraDate
|
||||
if (date?.includes('ago')) {
|
||||
const dayJSDate = dayjs(new Date()); // today
|
||||
const timeAgo = date.match(/\d+/)?.[0] || '';
|
||||
const timeAgoInt = parseInt(timeAgo, 10);
|
||||
|
||||
if (!timeAgo) return null; // there is no number!
|
||||
|
||||
if (date.includes('hours ago') || date.includes('hour ago')) {
|
||||
dayJSDate.subtract(timeAgoInt, 'hours'); // go back N hours
|
||||
}
|
||||
|
||||
if (date.includes('days ago') || date.includes('day ago')) {
|
||||
dayJSDate.subtract(timeAgoInt, 'days'); // go back N days
|
||||
}
|
||||
|
||||
if (date.includes('months ago') || date.includes('month ago')) {
|
||||
dayJSDate.subtract(timeAgoInt, 'months'); // go back N months
|
||||
}
|
||||
|
||||
if (date.includes('years ago') || date.includes('year ago')) {
|
||||
dayJSDate.subtract(timeAgoInt, 'years'); // go back N years
|
||||
}
|
||||
|
||||
return dayJSDate.toISOString();
|
||||
}
|
||||
return null; // there is no "ago" so give up
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const link = `${this.site}/ranking/${filters.order.value}/${page}`;
|
||||
const result = await fetchApi(link);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.category-items li').each((i, el) => {
|
||||
const novelUrl = loadedCheerio(el).find('.category-name a').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
const novelName = loadedCheerio(el)
|
||||
.find('.category-name a')
|
||||
.text()
|
||||
.trim();
|
||||
let novelCover = loadedCheerio(el).find('.category-img img').attr('src');
|
||||
|
||||
if (novelCover && !isUrlAbsolute(novelCover)) {
|
||||
novelCover = this.site + novelCover;
|
||||
}
|
||||
|
||||
const novel = {
|
||||
path: novelUrl?.replace(this.site, ''),
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
};
|
||||
novels.push(novel);
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const result = await fetchApi(this.site + novelPath);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.novel-title').text() || 'Untitled',
|
||||
cover: loadedCheerio('.novels-detail img').attr('src'),
|
||||
summary: loadedCheerio('.empty-box').text().trim(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
loadedCheerio('.novels-detail-right li').each((i, el) => {
|
||||
const detailName = loadedCheerio(el).find('div:first').text();
|
||||
const detail = loadedCheerio(el).find('div:last');
|
||||
|
||||
switch (detailName) {
|
||||
case 'Status:':
|
||||
novel.status = detail.text();
|
||||
break;
|
||||
case 'Genres:':
|
||||
novel.genres =
|
||||
detail
|
||||
.find('a')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(', ') || detail.text();
|
||||
break;
|
||||
case 'Author(s):':
|
||||
novel.author =
|
||||
detail
|
||||
.find('a')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(', ') || detail.text();
|
||||
break;
|
||||
case 'Translator:':
|
||||
novel.artist = detail.text();
|
||||
break;
|
||||
}
|
||||
});
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
loadedCheerio('.cm-tabs-content li').each((i, el) => {
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href');
|
||||
if (!chapterUrl) return;
|
||||
const chapterName = loadedCheerio(el).find('a').text().trim();
|
||||
const releaseDate = this.parseAgoDate(
|
||||
loadedCheerio(el).find('svg').attr('data-bs-original-title')!,
|
||||
);
|
||||
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
path: chapterUrl?.replace(this.site, ''),
|
||||
releaseTime: releaseDate,
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapter;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(this.site + chapterPath);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const chapterText = loadedCheerio('#chapterText').html() || '';
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url =
|
||||
this.site + `/search/autocomplete?dataType=json&query=${searchTerm}`;
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.json();
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
body.results.forEach(
|
||||
(item: { link: string; original_title: string; image: string }) =>
|
||||
novels.push({
|
||||
path: item.link,
|
||||
name: item.original_title,
|
||||
cover: item.image,
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
order: {
|
||||
value: 'top-rated',
|
||||
label: 'Order by',
|
||||
options: [
|
||||
{ label: 'MOST VIEWED', value: 'most-viewed' },
|
||||
{ label: 'TOP RATED', value: 'top-rated' },
|
||||
{ label: 'BOOKMARKS', value: 'subscribers' },
|
||||
{ label: 'NEW', value: 'new' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new ReadLiteNovel();
|
||||
@@ -0,0 +1,387 @@
|
||||
import { fetchFile, fetchText } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
import { load as loadCheerio, CheerioAPI } from 'cheerio';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
import { NovelItem } from '../../test_web/static/js';
|
||||
// import { isUrlAbsolute } from "@libs/isAbsoluteUrl";
|
||||
// import { parseMadaraDate } from "@libs/parseMadaraDate";
|
||||
|
||||
interface FuzzySearchOptions {
|
||||
caseSensitive: boolean;
|
||||
sort: boolean;
|
||||
}
|
||||
|
||||
// Shamelessly stolen from 'https://raw.githubusercontent.com/wouterrutgers/fuzzy-search/master/src/FuzzySearch.mjs'
|
||||
// I did modify the code a fair bit, but the algorithm is still the same under the hood.
|
||||
// The original code is under ISC, which stipluate that it needs to retain the copyright notice, so here it is.
|
||||
//
|
||||
// It only applies to the code in the FuzzySearch Class, even though I did modify it a fair bit
|
||||
|
||||
/*
|
||||
Copyright (c) 2016, Wouter Rutgers
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
*/
|
||||
|
||||
class FuzzySearch<Item> {
|
||||
private haystack: Item[];
|
||||
private options: FuzzySearchOptions;
|
||||
private getItems: (item: Item) => string[];
|
||||
|
||||
constructor(
|
||||
getItems: (item: Item) => string[],
|
||||
options: Partial<FuzzySearchOptions>,
|
||||
) {
|
||||
this.haystack = [];
|
||||
this.options = Object.assign(
|
||||
{
|
||||
caseSensitive: false,
|
||||
sort: false,
|
||||
},
|
||||
options,
|
||||
);
|
||||
this.getItems = getItems;
|
||||
}
|
||||
|
||||
getOptions(): FuzzySearchOptions {
|
||||
return this.options;
|
||||
}
|
||||
|
||||
setOptions(options: Partial<FuzzySearchOptions>) {
|
||||
this.options = Object.assign(this.options, options);
|
||||
}
|
||||
|
||||
setHaystack(items: Item[]) {
|
||||
this.haystack = items;
|
||||
}
|
||||
|
||||
getHaystack(): Item[] {
|
||||
return this.haystack;
|
||||
}
|
||||
|
||||
search(query: string): Item[] {
|
||||
if (query.length === 0) {
|
||||
return this.haystack;
|
||||
}
|
||||
const results: { item: Item; score: number }[] = [];
|
||||
|
||||
for (let item of this.haystack) {
|
||||
for (let value of this.getItems(item)) {
|
||||
const score = this.isMatch(value, query);
|
||||
if (score !== undefined) {
|
||||
results.push({ item, score });
|
||||
/// We don't want to have multiple occurence of the same item, so only take one if it is found
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.options.sort) {
|
||||
results.sort((a, b) => a.score - b.score);
|
||||
}
|
||||
|
||||
return results.map(result => result.item);
|
||||
}
|
||||
|
||||
isMatch(item: string, query: string): number | undefined {
|
||||
if (!this.options.caseSensitive) {
|
||||
item = item.toLocaleLowerCase();
|
||||
query = query.toLocaleLowerCase();
|
||||
}
|
||||
|
||||
const indexes = this.nearestIndexesFor(item, query);
|
||||
|
||||
if (indexes === undefined) {
|
||||
return;
|
||||
}
|
||||
// Exact matches should be first.
|
||||
if (item === query) {
|
||||
return 1;
|
||||
}
|
||||
// If we have more than 2 letters, matches close to each other should be first.
|
||||
if (indexes.length > 1) {
|
||||
return 2 + (indexes[indexes.length - 1] - indexes[0]);
|
||||
}
|
||||
// Matches closest to the start of the string should be first.
|
||||
return 2 + indexes[0];
|
||||
}
|
||||
|
||||
nearestIndexesFor(item: string, query: string): number[] | undefined {
|
||||
const letters = query.split('');
|
||||
let indexes: number[][] = [];
|
||||
|
||||
const idxFL = this.idxFirstLetter(item, query);
|
||||
|
||||
for (let idx of idxFL) {
|
||||
indexes.push([idx]);
|
||||
for (let i = 1; i < letters.length; i++) {
|
||||
const letter = letters[i];
|
||||
let index = item.indexOf(letter, idx + 1);
|
||||
if (index === -1) {
|
||||
indexes.pop();
|
||||
break;
|
||||
}
|
||||
indexes[indexes.length - 1].push(index);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
if (indexes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return indexes.sort((a, b) => {
|
||||
if (a.length === 1) {
|
||||
return a[0] - b[0];
|
||||
}
|
||||
|
||||
let res_a = a[a.length - 1] - a[0];
|
||||
let res_b = b[b.length - 1] - b[0];
|
||||
|
||||
return res_a - res_b;
|
||||
})[0];
|
||||
}
|
||||
|
||||
idxFirstLetter(item: string, query: string): number[] {
|
||||
const match = query[0];
|
||||
|
||||
return item
|
||||
.split('')
|
||||
.map((letter, index) => {
|
||||
if (letter !== match) {
|
||||
return;
|
||||
}
|
||||
return index;
|
||||
})
|
||||
.filter(index => index !== undefined) as number[];
|
||||
}
|
||||
}
|
||||
|
||||
class ReLibraryPlugin implements Plugin.PluginBase {
|
||||
id = 'ReLib';
|
||||
name = 'Re:Library';
|
||||
icon = 'src/en/relibrary/icon.png';
|
||||
site = 'https://re-library.com';
|
||||
version = '1.0.0';
|
||||
filters: Filters | undefined = undefined;
|
||||
|
||||
private searchFunc: FuzzySearch<Plugin.NovelItem> = new FuzzySearch(
|
||||
item => [item.name],
|
||||
{ sort: true, caseSensitive: false },
|
||||
);
|
||||
|
||||
private async popularNovelsInner(url: string): Promise<Plugin.NovelItem[]> {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
const result = await fetch(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
loadedCheerio('.entry-content > ol > li').each((_i, el) => {
|
||||
let novel: Partial<NovelItem> = {};
|
||||
novel.name = loadedCheerio(el).find('h3 > a').text();
|
||||
novel.path = loadedCheerio(el)
|
||||
.find('table > tbody > tr > td > a')
|
||||
.attr('href');
|
||||
if (novel.name === undefined || novel.path === undefined) return;
|
||||
novel.cover =
|
||||
loadedCheerio(el)
|
||||
.find('table > tbody > tr > td > a > img')
|
||||
.attr('src') || defaultCover;
|
||||
if (novel.path.startsWith(this.site)) {
|
||||
novel.path = novel.path.slice(this.site.length);
|
||||
}
|
||||
novels.push(novel as Plugin.NovelItem);
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
private async lastestNovelsInner(url: string): Promise<Plugin.NovelItem[]> {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
const result = await fetch(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
loadedCheerio('article.type-page.page').each((_i, el) => {
|
||||
let novel: Partial<Plugin.NovelItem> = {};
|
||||
novel.name = loadedCheerio(el).find('.entry-title').text();
|
||||
novel.path = loadedCheerio(el).find('.entry-title a').attr('href');
|
||||
if (novel.path === undefined || novel.name === undefined) return;
|
||||
novel.cover =
|
||||
loadedCheerio(el)
|
||||
.find('.entry-content > table > tbody > tr > td > img')
|
||||
.attr('src') || defaultCover;
|
||||
if (novel.path.startsWith(this.site)) {
|
||||
novel.path = novel.path.slice(this.site.length);
|
||||
}
|
||||
novels.push(novel as Plugin.NovelItem);
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
// The most-popular page only has a single page, so we return an empty array in case you ask for the impossible
|
||||
// the lastest page do have paginated result, so we support that.
|
||||
if (showLatestNovels)
|
||||
return this.lastestNovelsInner(
|
||||
`${this.site}/tag/translations/page/${pageNo}`,
|
||||
);
|
||||
else if (pageNo === 1)
|
||||
return this.popularNovelsInner(`${this.site}/translations/most-popular/`);
|
||||
else return [];
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
let novel: Partial<Plugin.SourceNovel> = {
|
||||
path: novelPath,
|
||||
};
|
||||
// title: .entry-content > header.entry-header > .entry-title
|
||||
// img: .entry-content > table > tbody > tr > td > img
|
||||
// tags: .entry-content > table > tbody > tr > td > p > span > a[]
|
||||
// synopis: .entry-content > div.su-box > div.su-box-content
|
||||
// chapters: .entry-content > div.su-accordion <then> li.page_item[]
|
||||
|
||||
const result = await fetch(`${this.site}/${novelPath}`);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
|
||||
// If it doesn't find the name I should just throw an error (or early return) since the scraping is broken
|
||||
novel.name = loadedCheerio('header.entry-header > .entry-title')
|
||||
.text()
|
||||
.trim();
|
||||
|
||||
if (novel.name === undefined || novel.name === '404 – Page not found')
|
||||
throw new Error(`Invalid novel for url ${novelPath}`);
|
||||
|
||||
// Find the cover
|
||||
novel.cover =
|
||||
loadedCheerio('.entry-content > table > tbody > tr > td > img').attr(
|
||||
'src',
|
||||
) || defaultCover;
|
||||
|
||||
// Genres in comma separated "list"
|
||||
novel.genres = (() => {
|
||||
let genres: string[] = [];
|
||||
loadedCheerio(
|
||||
'.entry-content > table > tbody > tr > td > p > span > a',
|
||||
).each((_i, el) => {
|
||||
genres.push(loadedCheerio(el).text().trim());
|
||||
});
|
||||
return genres.join(', ');
|
||||
})();
|
||||
|
||||
// Handle the novel status
|
||||
// Sadly some novels just state the status inside the summary...
|
||||
// I don't even know if the snippet here works for *most* of the novels preset, or only for a few
|
||||
loadedCheerio('.entry-content > table > tbody > tr > td > p').each(
|
||||
function (_i, el) {
|
||||
if (
|
||||
loadedCheerio(el)
|
||||
.find('strong')
|
||||
.text()
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.startsWith('status')
|
||||
) {
|
||||
loadedCheerio(el).find('strong').remove();
|
||||
novel.status = loadedCheerio(el).text();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
novel.summary = loadedCheerio(
|
||||
'.entry-content > div.su-box > div.su-box-content',
|
||||
).text();
|
||||
|
||||
let chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
let chapter_idx = 0;
|
||||
loadedCheerio('.entry-content > div.su-accordion').each((_i1, el) => {
|
||||
loadedCheerio(el)
|
||||
.find('li.page_item > a')
|
||||
.each((_i2, chap_el) => {
|
||||
chapter_idx += 1;
|
||||
let chap_path = loadedCheerio(chap_el).attr('href')?.trim();
|
||||
if (
|
||||
loadedCheerio(chap_el).text() === undefined ||
|
||||
chap_path === undefined
|
||||
)
|
||||
return;
|
||||
if (chap_path.startsWith(this.site)) {
|
||||
chap_path = chap_path.slice(this.site.length);
|
||||
}
|
||||
chapters.push({
|
||||
name: loadedCheerio(chap_el).text(),
|
||||
path: chap_path,
|
||||
chapterNumber: chapter_idx,
|
||||
// we KNOW that we can't get the released time (at least without any additional fetches), so set it to null purposfully
|
||||
releaseTime: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapters;
|
||||
return novel as Plugin.NovelItem;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
// parse chapter text here
|
||||
const result = await fetch(`${this.site}/${chapterPath}`);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
let text: string[] = [];
|
||||
loadedCheerio('.entry-content > p')
|
||||
.slice(1)
|
||||
.each((_i, el) => {
|
||||
loadedCheerio(el).find('span').remove();
|
||||
let t = loadedCheerio(el).html();
|
||||
if (t === undefined) return;
|
||||
text.push(`<p>${t}</p>`);
|
||||
});
|
||||
if (text.length == 0)
|
||||
throw new Error(
|
||||
`Invalid Parsing of chapter, couldn't find any text for url "${this.site}/${chapterPath}"`,
|
||||
);
|
||||
return text.join('');
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
// We only want to serve a single "page" since we do the search client side.
|
||||
if (pageNo !== 1) return [];
|
||||
|
||||
let novels: Plugin.NovelItem[] = [];
|
||||
const req = await fetch(`${this.site}/translations/`);
|
||||
const body = await req.text();
|
||||
|
||||
const loadedCheerio = loadCheerio(body);
|
||||
|
||||
loadedCheerio('.entry-content table a').each((_i, el) => {
|
||||
let e = loadedCheerio(el);
|
||||
if (e && e.attr('href') && e.text()) {
|
||||
let path = e.attr('href')!;
|
||||
if (path.startsWith(this.site)) {
|
||||
path = path.slice(this.site.length);
|
||||
}
|
||||
novels.push({ name: e.text(), path: path, cover: defaultCover });
|
||||
}
|
||||
});
|
||||
this.searchFunc.setHaystack(novels);
|
||||
return this.searchFunc.search(searchTerm);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ReLibraryPlugin();
|
||||
@@ -0,0 +1,401 @@
|
||||
import { Parser } from 'htmlparser2';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class RoyalRoad implements Plugin.PluginBase {
|
||||
id = 'royalroad';
|
||||
name = 'Royal Road';
|
||||
version = '2.0.0';
|
||||
icon = 'src/en/royalroad/icon.png';
|
||||
site = 'https://www.royalroad.com/';
|
||||
|
||||
parseNovels(html: string) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
let tempNovel = {} as Plugin.NovelItem;
|
||||
tempNovel.name = '';
|
||||
let isParsingNovel = false;
|
||||
let isNovelName = false;
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (attribs['class']?.includes('fiction-list-item')) {
|
||||
isParsingNovel = true;
|
||||
}
|
||||
if (isParsingNovel) {
|
||||
if (name === 'a' && attribs['class']?.includes('bold')) {
|
||||
tempNovel.path = attribs['href'].slice(1);
|
||||
isNovelName = true;
|
||||
}
|
||||
if (name === 'img') {
|
||||
tempNovel.cover = attribs['src'];
|
||||
}
|
||||
if (tempNovel.path && tempNovel.name) {
|
||||
novels.push(tempNovel);
|
||||
tempNovel = {} as Plugin.NovelItem;
|
||||
tempNovel.name = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isNovelName) {
|
||||
tempNovel.name += data;
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'h2') {
|
||||
isNovelName = false;
|
||||
isParsingNovel = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}fictions/`;
|
||||
|
||||
link += filters.order.value;
|
||||
link += `?page=${page}`;
|
||||
|
||||
if (filters.genre.value !== '') link += `&genre=${filters.genre.value}`;
|
||||
|
||||
const body = await fetchApi(link).then(r => r.text());
|
||||
|
||||
return this.parseNovels(body);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const result = await fetchApi(this.site + novelPath);
|
||||
const html = await result.text();
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: '',
|
||||
summary: '',
|
||||
chapters: [],
|
||||
};
|
||||
let isNovelName = false;
|
||||
let isAuthorName = false;
|
||||
let isDescription = false;
|
||||
let isH4 = false;
|
||||
let isSpan = 0;
|
||||
let isTags = false;
|
||||
let isGenres = false;
|
||||
let genreArray: string[] = [];
|
||||
let isFooter = false;
|
||||
let isScript = false;
|
||||
let chapterJson: ChapterEntry[] = [];
|
||||
let volumeJson: VolumeEntry[] = [];
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (name === 'img' && attribs['class']?.includes('thumbnail')) {
|
||||
novel.cover = attribs['src'];
|
||||
}
|
||||
if (name === 'span' && attribs['class']?.includes('label-sm')) {
|
||||
isSpan++;
|
||||
}
|
||||
if (name === 'span' && attribs['class']?.includes('tags')) {
|
||||
isTags = true;
|
||||
}
|
||||
},
|
||||
onopentagname(name) {
|
||||
if (name === 'h1') {
|
||||
isNovelName = true;
|
||||
}
|
||||
if (isH4 && name === 'a') {
|
||||
isAuthorName = true;
|
||||
}
|
||||
if (isTags && name === 'a') {
|
||||
isGenres = true;
|
||||
}
|
||||
if (name === 'label') {
|
||||
isDescription = false;
|
||||
isTags = false;
|
||||
}
|
||||
if (isFooter && name === 'script') {
|
||||
isScript = true;
|
||||
}
|
||||
},
|
||||
onattribute(name, value) {
|
||||
if (name === 'class' && value === 'description') {
|
||||
isDescription = true;
|
||||
}
|
||||
if (name === 'class' && value === 'page-footer footer') {
|
||||
isFooter = true;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isNovelName) {
|
||||
novel.name = data;
|
||||
}
|
||||
if (isAuthorName) {
|
||||
novel.author = data;
|
||||
isAuthorName = false;
|
||||
}
|
||||
if (isDescription) {
|
||||
novel.summary += data;
|
||||
}
|
||||
if (isSpan === 2) {
|
||||
novel.status = data.trim();
|
||||
isSpan++;
|
||||
}
|
||||
if (isGenres) {
|
||||
genreArray.push(data);
|
||||
}
|
||||
if (isScript) {
|
||||
if (data.includes('window.chapters =')) {
|
||||
chapterJson = JSON.parse(
|
||||
data.match(/window.chapters = (.+])(?=;)/)![1],
|
||||
);
|
||||
volumeJson = JSON.parse(
|
||||
data.match(/window.volumes = (.+])(?=;)/)![1],
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'h1') {
|
||||
isNovelName = false;
|
||||
isH4 = true;
|
||||
}
|
||||
if (name === 'h4') {
|
||||
isH4 = false;
|
||||
}
|
||||
if (name === 'a') {
|
||||
isGenres = false;
|
||||
}
|
||||
if (name === 'script') {
|
||||
isScript = false;
|
||||
}
|
||||
if (name === 'body') {
|
||||
isFooter = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
novel.summary = novel.summary?.trim();
|
||||
novel.genres = genreArray.join(', ');
|
||||
switch (novel.status) {
|
||||
case 'ONGOING':
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
break;
|
||||
case 'HIATUS':
|
||||
novel.status = NovelStatus.OnHiatus;
|
||||
break;
|
||||
case 'COMPLETED':
|
||||
novel.status = NovelStatus.Completed;
|
||||
break;
|
||||
default:
|
||||
novel.status = NovelStatus.Unknown;
|
||||
}
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = chapterJson.map(
|
||||
(chapter: ChapterEntry) => {
|
||||
const matchingVolume = volumeJson.find(
|
||||
(volume: VolumeEntry) => volume.id === chapter.volumeId,
|
||||
);
|
||||
return {
|
||||
name: chapter.title,
|
||||
path: chapter.url.slice(1),
|
||||
releaseTime: chapter.date,
|
||||
chapterNumber: chapter?.order,
|
||||
page: matchingVolume?.title,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
novel.chapters = chapter;
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(this.site + chapterPath);
|
||||
const html = await result.text();
|
||||
let chapterText = '';
|
||||
let isChapter = false;
|
||||
let isPtag = false;
|
||||
let isStyle = false;
|
||||
let isStyleText = false;
|
||||
let styles = '';
|
||||
const parser = new Parser({
|
||||
onopentag(name, attribs) {
|
||||
if (isChapter && name === 'div') {
|
||||
let stylediv = attribs['style'];
|
||||
if (stylediv) {
|
||||
chapterText += `<div style="${stylediv}">`;
|
||||
isStyleText = true;
|
||||
} else {
|
||||
chapterText += `<div>`;
|
||||
}
|
||||
}
|
||||
if (isChapter && name === 'table') {
|
||||
let w = attribs['width'];
|
||||
if (w) {
|
||||
chapterText += `<table width="${w}">`;
|
||||
} else {
|
||||
chapterText += `<table>`;
|
||||
}
|
||||
}
|
||||
if (isChapter && name === 'tbody') {
|
||||
chapterText += `<tbody>`;
|
||||
}
|
||||
if (isChapter && name === 'tr') {
|
||||
chapterText += `<tr>`;
|
||||
}
|
||||
if (isChapter && name === 'td') {
|
||||
let w1 = attribs['width'];
|
||||
if (w1) {
|
||||
chapterText += `<td width="${w1}">`;
|
||||
} else {
|
||||
chapterText += `<td>`;
|
||||
}
|
||||
}
|
||||
},
|
||||
onattribute(name, value) {
|
||||
if (name === 'class' && value === 'chapter-inner chapter-content') {
|
||||
isChapter = true;
|
||||
}
|
||||
if (name === 'class' && value === 'portlet light t-center-3') {
|
||||
isChapter = false;
|
||||
isPtag = false;
|
||||
}
|
||||
if (name === 'class' && value === styles) {
|
||||
isPtag = false;
|
||||
}
|
||||
},
|
||||
onopentagname(name) {
|
||||
if (isChapter && name === 'p') {
|
||||
chapterText += '<p>';
|
||||
isPtag = true;
|
||||
if (isStyleText) {
|
||||
isStyleText = false;
|
||||
}
|
||||
}
|
||||
if (name === 'style') {
|
||||
isStyle = true;
|
||||
}
|
||||
if (isChapter && name === 'br') {
|
||||
chapterText += `<br>`;
|
||||
}
|
||||
},
|
||||
ontext(data) {
|
||||
if (isPtag) {
|
||||
chapterText += data;
|
||||
}
|
||||
if (isStyleText) {
|
||||
chapterText += data;
|
||||
}
|
||||
if (isStyle) {
|
||||
if (data.includes('display: none;')) {
|
||||
styles = data.match(/\.(.*)\{/)![1];
|
||||
}
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
if (name === 'p') {
|
||||
isPtag = false;
|
||||
chapterText += '</p>';
|
||||
}
|
||||
if (name === 'style') {
|
||||
isStyle = false;
|
||||
}
|
||||
if (isChapter && name === 'td') {
|
||||
chapterText += `</td>`;
|
||||
}
|
||||
if (isChapter && name === 'tr') {
|
||||
chapterText += `</tr>`;
|
||||
}
|
||||
if (isChapter && name === 'tbody') {
|
||||
chapterText += `</tbody>`;
|
||||
}
|
||||
if (isChapter && name === 'table') {
|
||||
chapterText += `</table>`;
|
||||
}
|
||||
if (isChapter && name === 'div') {
|
||||
isStyleText = false;
|
||||
chapterText += `</div>`;
|
||||
}
|
||||
},
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
page: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const searchUrl = `${this.site}fictions/search?page=${page}&title=${searchTerm}`;
|
||||
|
||||
const body = await fetchApi(searchUrl).then(r => r.text());
|
||||
return this.parseNovels(body);
|
||||
}
|
||||
|
||||
filters = {
|
||||
order: {
|
||||
value: 'weekly-popular',
|
||||
label: 'Order By',
|
||||
options: [
|
||||
{ label: 'Best Rated', value: 'best-rated' },
|
||||
{ label: 'Trending', value: 'trending' },
|
||||
{ label: 'Ongoing Fictions', value: 'active-popular' },
|
||||
{ label: 'Complete', value: 'complete' },
|
||||
{ label: 'Popular this week', value: 'weekly-popular' },
|
||||
{ label: 'Latest Updates', value: 'latest-updates' },
|
||||
{ label: 'Newest Fictions', value: 'new' },
|
||||
{ label: 'Rising Stars', value: 'rising-stars' },
|
||||
{ label: 'Writathon', value: 'writathon' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genre: {
|
||||
value: '',
|
||||
label: 'Genres',
|
||||
options: [
|
||||
{ label: 'ALL', value: '' },
|
||||
{ label: 'Action', value: 'action' },
|
||||
{ label: 'Adventure', value: 'adventure' },
|
||||
{ label: 'Comedy', value: 'comedy' },
|
||||
{ label: 'Contemporary', value: 'contemporary' },
|
||||
{ label: 'Drama', value: 'drama' },
|
||||
{ label: 'Fantasy', value: 'fantasy' },
|
||||
{ label: 'Historical', value: 'historical' },
|
||||
{ label: 'Horror', value: 'horror' },
|
||||
{ label: 'Mystery', value: 'mystery' },
|
||||
{ label: 'Psychological', value: 'psychological' },
|
||||
{ label: 'Romance', value: 'romance' },
|
||||
{ label: 'Satire', value: 'satire' },
|
||||
{ label: 'Sci-fi', value: 'sci_fi' },
|
||||
{ label: 'Short Story', value: 'one_shot' },
|
||||
{ label: 'Tragedy', value: 'tragedy' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new RoyalRoad();
|
||||
|
||||
interface ChapterEntry {
|
||||
id: number;
|
||||
volumeId: number;
|
||||
title: string;
|
||||
date: string;
|
||||
order: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface VolumeEntry {
|
||||
id: number;
|
||||
title: string;
|
||||
cover: string;
|
||||
order: number;
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
class ScribbleHubPlugin implements Plugin.PluginBase {
|
||||
id = 'scribblehub';
|
||||
name = 'ScribbleHub';
|
||||
icon = 'src/en/scribblehub/icon.png';
|
||||
site = 'https://www.scribblehub.com/';
|
||||
version = '1.0.0';
|
||||
parseNovels(loadedCheerio: CheerioAPI) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
loadedCheerio('.search_main_box').each((i, el) => {
|
||||
const novelName = loadedCheerio(el).find('.search_title > a').text();
|
||||
const novelCover = loadedCheerio(el)
|
||||
.find('.search_img > img')
|
||||
.attr('src');
|
||||
const novelUrl = loadedCheerio(el).find('.search_title > a').attr('href');
|
||||
|
||||
if (!novelUrl) return;
|
||||
|
||||
const novel = {
|
||||
name: novelName,
|
||||
cover: novelCover,
|
||||
path: novelUrl.replace(this.site, ''),
|
||||
};
|
||||
novels.push(novel);
|
||||
});
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = `${this.site}`;
|
||||
if (showLatestNovels) link += 'latest-series/';
|
||||
// if (filters.genres.value.include?.length ||
|
||||
// filters.genres.value.exclude?.length)
|
||||
else link += 'series-finder/?sf=1';
|
||||
// else
|
||||
// link += "series-ranking/";
|
||||
// TODO, series-ranking filters when hideOnSelect come out
|
||||
|
||||
if (filters.genres.value.include?.length)
|
||||
link += '&gi=' + filters.genres.value.include.join(',');
|
||||
|
||||
if (filters.genres.value.exclude?.length)
|
||||
link += '&ge=' + filters.genres.value.exclude.join(',');
|
||||
|
||||
if (
|
||||
filters.genres.value.include?.length ||
|
||||
filters.genres.value.exclude?.length
|
||||
)
|
||||
link += '&mgi=' + filters.genre_operator.value;
|
||||
|
||||
if (filters.content_warning.value.include?.length)
|
||||
link += '&cti=' + filters.content_warning.value.include.join(',');
|
||||
|
||||
if (filters.content_warning.value.exclude?.length)
|
||||
link += '&cte=' + filters.content_warning.value.exclude.join(',');
|
||||
|
||||
if (
|
||||
filters.content_warning.value.include?.length ||
|
||||
filters.content_warning.value.exclude?.length
|
||||
)
|
||||
link += '&mct' + filters.content_warning_operator.value;
|
||||
|
||||
link += '&sort=' + filters.sort.value;
|
||||
link += '&order=' + filters.order.value;
|
||||
|
||||
const body = await fetchApi(link).then(result => result.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);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('.fic_title').text() || 'Untitled',
|
||||
cover: loadedCheerio('.fic_image > img').attr('src'),
|
||||
summary: loadedCheerio('.wi_fic_desc').text(),
|
||||
author: loadedCheerio('.auth_name_fic').text(),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
novel.genres = loadedCheerio('.fic_genre')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
novel.status = loadedCheerio('.rnd_stats').next().text().includes('Ongoing')
|
||||
? 'Ongoing'
|
||||
: 'Completed';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('action', 'wi_getreleases_pagination');
|
||||
formData.append('pagenum', '-1');
|
||||
formData.append('mypostid', novelPath.split('/')[1]);
|
||||
|
||||
const data = await fetchApi(`${this.site}wp-admin/admin-ajax.php`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const text = await data.text();
|
||||
|
||||
loadedCheerio = parseHTML(text);
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = [];
|
||||
|
||||
const parseISODate = (date: string) => {
|
||||
if (date.includes('ago')) {
|
||||
const dayJSDate = dayjs(new Date()); // today
|
||||
const timeAgo = date.match(/\d+/)?.[0] || '';
|
||||
const timeAgoInt = parseInt(timeAgo, 10);
|
||||
|
||||
if (!timeAgo) return null; // there is no number!
|
||||
|
||||
if (date.includes('hours ago') || date.includes('hour ago')) {
|
||||
dayJSDate.subtract(timeAgoInt, 'hours'); // go back N hours
|
||||
}
|
||||
|
||||
if (date.includes('days ago') || date.includes('day ago')) {
|
||||
dayJSDate.subtract(timeAgoInt, 'days'); // go back N days
|
||||
}
|
||||
|
||||
if (date.includes('months ago') || date.includes('month ago')) {
|
||||
dayJSDate.subtract(timeAgoInt, 'months'); // go back N months
|
||||
}
|
||||
|
||||
return dayJSDate.toISOString();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
loadedCheerio('.toc_w').each((i, el) => {
|
||||
const chapterName = loadedCheerio(el).find('.toc_a').text();
|
||||
const releaseDate = loadedCheerio(el).find('.fic_date_pub').text();
|
||||
const chapterUrl = loadedCheerio(el).find('a').attr('href');
|
||||
|
||||
if (!chapterUrl) return;
|
||||
chapter.push({
|
||||
name: chapterName,
|
||||
releaseTime: parseISODate(releaseDate),
|
||||
path: chapterUrl.replace(this.site, ''),
|
||||
});
|
||||
});
|
||||
|
||||
novel.chapters = chapter.reverse();
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const result = await fetchApi(this.site + chapterPath);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const chapterText = loadedCheerio('div.chp_raw').html() || '';
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const url = `${this.site}?s=${searchTerm}&post_type=fictionposts`;
|
||||
const result = await fetchApi(url);
|
||||
const body = await result.text();
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
return this.parseNovels(loadedCheerio);
|
||||
}
|
||||
|
||||
filters = {
|
||||
sort: {
|
||||
label: 'Sort Results By',
|
||||
value: 'ratings',
|
||||
options: [
|
||||
{ label: 'Chapters', value: 'chapters' },
|
||||
{ label: 'Chapters per week', value: 'frequency' },
|
||||
{ label: 'Date Added', value: 'dateadded' },
|
||||
{ label: 'Favorites', value: 'favorites' },
|
||||
{ label: 'Last Updated', value: 'lastchdate' },
|
||||
{ label: 'Number of Ratings', value: 'numofrate' },
|
||||
{ label: 'Pages', value: 'pages' },
|
||||
{ label: 'Pageviews', value: 'pageviews' },
|
||||
{ label: 'Ratings', value: 'ratings' },
|
||||
{ label: 'Readers', value: 'readers' },
|
||||
{ label: 'Reviews', value: 'reviews' },
|
||||
{ label: 'Total Words', value: 'totalwords' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
order: {
|
||||
label: 'Order by',
|
||||
value: 'desc',
|
||||
options: [
|
||||
{ label: 'Descending', value: 'desc' },
|
||||
{ label: 'Ascending', value: 'asc' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
storyStatus: {
|
||||
label: 'Story Status (Translation)',
|
||||
value: '',
|
||||
options: [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Completed', value: 'completed' },
|
||||
{ label: 'Ongoing', value: 'ongoing' },
|
||||
{ label: 'Hiatus', value: 'hiatus' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
genres: {
|
||||
label: 'Genres',
|
||||
value: {
|
||||
include: [],
|
||||
exclude: [],
|
||||
},
|
||||
options: [
|
||||
{ label: 'Action', value: '9' },
|
||||
{ label: 'Adult', value: '902' },
|
||||
{ label: 'Adventure', value: '8' },
|
||||
{ label: 'Boys Love', value: '891' },
|
||||
{ label: 'Comedy', value: '7' },
|
||||
{ label: 'Drama', value: '903' },
|
||||
{ label: 'Ecchi', value: '904' },
|
||||
{ label: 'Fanfiction', value: '38' },
|
||||
{ label: 'Fantasy', value: '19' },
|
||||
{ label: 'Gender Bender', value: '905' },
|
||||
{ label: 'Girls Love', value: '892' },
|
||||
{ label: 'Harem', value: '1015' },
|
||||
{ label: 'Historical', value: '21' },
|
||||
{ label: 'Horror', value: '22' },
|
||||
{ label: 'Isekai', value: '37' },
|
||||
{ label: 'Josei', value: '906' },
|
||||
{ label: 'LitRPG', value: '1180' },
|
||||
{ label: 'Martial Arts', value: '907' },
|
||||
{ label: 'Mature', value: '20' },
|
||||
{ label: 'Mecha', value: '908' },
|
||||
{ label: 'Mystery', value: '909' },
|
||||
{ label: 'Psychological', value: '910' },
|
||||
{ label: 'Romance', value: '6' },
|
||||
{ label: 'School Life', value: '911' },
|
||||
{ label: 'Sci-fi', value: '912' },
|
||||
{ label: 'Seinen', value: '913' },
|
||||
{ label: 'Slice of Life', value: '914' },
|
||||
{ label: 'Smut', value: '915' },
|
||||
{ label: 'Sports', value: '916' },
|
||||
{ label: 'Supernatural', value: '5' },
|
||||
{ label: 'Tragedy', value: '901' },
|
||||
],
|
||||
type: FilterTypes.ExcludableCheckboxGroup,
|
||||
},
|
||||
genre_operator: {
|
||||
value: 'and',
|
||||
label: 'Genre And/Or',
|
||||
options: [
|
||||
{ label: 'OR', value: 'or' },
|
||||
{ label: 'AND', value: 'and' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
content_warning: {
|
||||
value: {
|
||||
include: [],
|
||||
exclude: [],
|
||||
},
|
||||
label: 'Mature Content',
|
||||
options: [
|
||||
{ label: 'Gore', value: '48' },
|
||||
{ label: 'Sexual Content', value: '50' },
|
||||
{ label: 'Strong Language', value: '49' },
|
||||
],
|
||||
type: FilterTypes.ExcludableCheckboxGroup,
|
||||
},
|
||||
content_warning_operator: {
|
||||
value: 'and',
|
||||
label: 'Mature Content And/Or',
|
||||
options: [
|
||||
{ label: 'OR', value: 'or' },
|
||||
{ label: 'AND', value: 'and' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
export default new ScribbleHubPlugin();
|
||||
@@ -0,0 +1,298 @@
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
|
||||
class WTRLAB implements Plugin.PluginBase {
|
||||
id = 'WTRLAB';
|
||||
name = 'WTR-LAB';
|
||||
site = 'https://wtr-lab.com/';
|
||||
version = '1.0.1';
|
||||
icon = 'src/en/wtrlab/icon.png';
|
||||
sourceLang = 'en/';
|
||||
|
||||
async popularNovels(
|
||||
page: number,
|
||||
{
|
||||
showLatestNovels,
|
||||
filters,
|
||||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
let link = this.site + this.sourceLang + 'novel-list?';
|
||||
link += `orderBy=${filters.order.value}`;
|
||||
link += `&order=${filters.sort.value}`;
|
||||
link += `&filter=${filters.storyStatus.value}`;
|
||||
link += `&page=${page}`; //TODO Genre & Advance Searching Filter. Ez to implement, too much manual work, too lazy.
|
||||
|
||||
if (showLatestNovels) {
|
||||
const response = await fetch('https://wtr-lab.com/api/home/recent', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ page: page }),
|
||||
});
|
||||
|
||||
const recentNovel: JsonNovel = await response.json();
|
||||
|
||||
// Parse novels from JSON
|
||||
const novels: Plugin.NovelItem[] = recentNovel.data.map(
|
||||
(datum: Datum) => ({
|
||||
name: datum.serie.data.title || '',
|
||||
cover: datum.serie.data.image,
|
||||
path:
|
||||
this.sourceLang +
|
||||
'serie-' +
|
||||
datum.serie.raw_id +
|
||||
'/' +
|
||||
datum.serie.slug || '',
|
||||
}),
|
||||
);
|
||||
|
||||
return novels;
|
||||
} else {
|
||||
const body = await fetchApi(link).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const novels: Plugin.NovelItem[] = loadedCheerio('.serie-item')
|
||||
.map((index, element) => ({
|
||||
name:
|
||||
loadedCheerio(element)
|
||||
.find('.title-wrap > a')
|
||||
.text()
|
||||
.replace(loadedCheerio(element).find('.rawtitle').text(), '') ||
|
||||
'',
|
||||
cover: loadedCheerio(element).find('img').attr('src'),
|
||||
path: loadedCheerio(element).find('a').attr('href') || '',
|
||||
}))
|
||||
.get()
|
||||
.filter(novel => novel.name && novel.path);
|
||||
return novels;
|
||||
}
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const body = await fetchApi(this.site + novelPath).then(res => res.text());
|
||||
const loadedCheerio = parseHTML(body);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: loadedCheerio('h1.text-uppercase').text(),
|
||||
cover: loadedCheerio('.img-wrap > img').attr('src'),
|
||||
summary: loadedCheerio('.lead').text().trim(),
|
||||
};
|
||||
|
||||
novel.genres = loadedCheerio('td:contains("Genre")')
|
||||
.next()
|
||||
.find('a')
|
||||
.map((i, el) => loadedCheerio(el).text())
|
||||
.toArray()
|
||||
.join(',');
|
||||
|
||||
novel.author = loadedCheerio('td:contains("Author")')
|
||||
.next()
|
||||
.text()
|
||||
.replace(/[\t\n]/g, '');
|
||||
|
||||
novel.status = loadedCheerio('td:contains("Status")')
|
||||
.next()
|
||||
.text()
|
||||
.replace(/[\t\n]/g, '');
|
||||
|
||||
const chapterJson = loadedCheerio('#__NEXT_DATA__').html() + '';
|
||||
const jsonData: NovelJson = JSON.parse(chapterJson);
|
||||
|
||||
const chapters: Plugin.ChapterItem[] =
|
||||
jsonData.props.pageProps.serie.chapters.map(
|
||||
(jsonChapter, chapterIndex) => ({
|
||||
name: jsonChapter.title,
|
||||
path:
|
||||
this.sourceLang +
|
||||
'serie-' +
|
||||
jsonData.props.pageProps.serie.serie_data.raw_id +
|
||||
'/' +
|
||||
jsonData.props.pageProps.serie.serie_data.slug +
|
||||
'/chapter-' +
|
||||
jsonChapter.order, // Assuming 'slug' is the intended path
|
||||
releaseTime: (
|
||||
jsonChapter?.created_at || jsonChapter?.updated_at
|
||||
)?.substring(0, 10),
|
||||
chapterNumber: chapterIndex + 1,
|
||||
}),
|
||||
);
|
||||
|
||||
novel.chapters = chapters;
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const body = await fetchApi(this.site + chapterPath).then(res =>
|
||||
res.text(),
|
||||
);
|
||||
|
||||
const loadedCheerio = parseHTML(body);
|
||||
const chapterJson = loadedCheerio('#__NEXT_DATA__').html() + '';
|
||||
const jsonData: NovelJson = JSON.parse(chapterJson);
|
||||
|
||||
const chapterContent = JSON.stringify(
|
||||
jsonData.props.pageProps.serie.chapter_data.data.body,
|
||||
);
|
||||
const parsedArray = JSON.parse(chapterContent);
|
||||
let htmlString = '';
|
||||
|
||||
for (const text of parsedArray) {
|
||||
htmlString += `<p>${text}</p>`;
|
||||
}
|
||||
|
||||
return htmlString;
|
||||
}
|
||||
|
||||
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
|
||||
const response = await fetch(this.site + 'api/search', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Referer: this.site + this.sourceLang,
|
||||
Origin: this.site,
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text: searchTerm }),
|
||||
});
|
||||
|
||||
const recentNovel: JsonNovel = await response.json();
|
||||
|
||||
// Parse novels from JSON
|
||||
const novels: Plugin.NovelItem[] = recentNovel.data.map((datum: Datum) => ({
|
||||
name: datum.data.title || '',
|
||||
cover: datum.data.image,
|
||||
path: this.sourceLang + 'serie-' + datum.raw_id + '/' + datum.slug || '',
|
||||
}));
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
filters = {
|
||||
order: {
|
||||
value: 'chapter',
|
||||
label: 'Order by',
|
||||
options: [
|
||||
{ label: 'View', value: 'view' },
|
||||
{ label: 'Name', value: 'name' },
|
||||
{ label: 'Addition Date', value: 'date' },
|
||||
{ label: 'Reader', value: 'reader' },
|
||||
{ label: 'Chapter', value: 'chapter' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
sort: {
|
||||
value: 'desc',
|
||||
label: 'Sort by',
|
||||
options: [
|
||||
{ label: 'Descending', value: 'desc' },
|
||||
{ label: 'Ascending', value: 'asc' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
storyStatus: {
|
||||
value: 'all',
|
||||
label: 'Status',
|
||||
options: [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Ongoing', value: 'ongoing' },
|
||||
{ label: 'Completed', value: 'completed' },
|
||||
],
|
||||
type: FilterTypes.Picker,
|
||||
},
|
||||
} satisfies Filters;
|
||||
}
|
||||
|
||||
interface NovelJson {
|
||||
props: Props;
|
||||
page: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
pageProps: PageProps;
|
||||
__N_SSP: boolean;
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
serie: Serie;
|
||||
server_time: Date;
|
||||
}
|
||||
|
||||
interface Serie {
|
||||
serie_data: SerieData;
|
||||
chapters: Chapter[];
|
||||
recommendation: SerieData[];
|
||||
chapter_data: ChapterData;
|
||||
id: number;
|
||||
raw_id: number;
|
||||
slug: string;
|
||||
data: Data;
|
||||
is_default: boolean;
|
||||
raw_type: string;
|
||||
}
|
||||
|
||||
interface Chapter {
|
||||
serie_id: number;
|
||||
id: number;
|
||||
order: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
interface ChapterData {
|
||||
data: ChapterContent;
|
||||
}
|
||||
interface ChapterContent {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
interface SerieData {
|
||||
serie_id?: number;
|
||||
recommendation_id?: number;
|
||||
score?: string;
|
||||
id: number;
|
||||
slug: string;
|
||||
search_text: string;
|
||||
status: number;
|
||||
data: Data;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
view: number;
|
||||
in_library: number;
|
||||
rating: number | null;
|
||||
chapter_count: number;
|
||||
power: number;
|
||||
total_rate: number;
|
||||
user_status: number;
|
||||
verified: boolean;
|
||||
from: null;
|
||||
raw_id: number;
|
||||
genres?: number[];
|
||||
}
|
||||
|
||||
interface Data {
|
||||
title: string;
|
||||
author: string;
|
||||
description: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
interface JsonNovel {
|
||||
success: boolean;
|
||||
data: Datum[];
|
||||
}
|
||||
interface Datum {
|
||||
serie: Serie;
|
||||
chapters: Chapter[];
|
||||
updated_at: Date;
|
||||
raw_id: number;
|
||||
slug: string;
|
||||
data: Data;
|
||||
}
|
||||
|
||||
export default new WTRLAB();
|
||||
@@ -0,0 +1,474 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { fetchApi, fetchFile, fetchProto } from '@libs/fetch';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
interface StringValue {
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface BoolValue {
|
||||
value: boolean;
|
||||
}
|
||||
|
||||
interface DecimalValue {
|
||||
units: bigint;
|
||||
nanos: number;
|
||||
}
|
||||
|
||||
interface Timestamp {
|
||||
seconds: number;
|
||||
nanos: number;
|
||||
}
|
||||
|
||||
interface NovelEntry {
|
||||
name: string;
|
||||
coverUrl: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface RelatedChapterUserInfo {
|
||||
isChapterUnlocked?: BoolValue;
|
||||
}
|
||||
|
||||
interface ChapterItem {
|
||||
entityId: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
number?: DecimalValue;
|
||||
content?: StringValue;
|
||||
relatedUserInfo?: RelatedChapterUserInfo;
|
||||
offset: number;
|
||||
publishedAt?: Timestamp;
|
||||
}
|
||||
|
||||
interface GetChapterResponse {
|
||||
item?: ChapterItem;
|
||||
}
|
||||
|
||||
interface ChapterGroupCounts {
|
||||
total: number;
|
||||
advance: number;
|
||||
normal: number;
|
||||
}
|
||||
|
||||
interface ChapterGroupItem {
|
||||
id: number;
|
||||
title: string;
|
||||
order: number;
|
||||
fromChapterNumber?: DecimalValue;
|
||||
toChapterNumber?: DecimalValue;
|
||||
chapterList: ChapterItem[];
|
||||
counts?: ChapterGroupCounts;
|
||||
}
|
||||
|
||||
interface GetChapterListResponse {
|
||||
items: ChapterGroupItem[];
|
||||
}
|
||||
|
||||
enum NovelItem_Status {
|
||||
Finished = 0,
|
||||
Active = 1,
|
||||
Hiatus = 2,
|
||||
All = -1,
|
||||
}
|
||||
|
||||
interface NovelKarmaInfo {
|
||||
isActive: boolean;
|
||||
isFree: boolean;
|
||||
maxFreeChapter?: DecimalValue;
|
||||
canUnlockWithVip: boolean;
|
||||
}
|
||||
|
||||
interface NovelItem {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
status: NovelItem_Status;
|
||||
visible: boolean;
|
||||
description?: StringValue;
|
||||
synopsis?: StringValue;
|
||||
coverUrl?: StringValue;
|
||||
translatorName?: StringValue;
|
||||
authorName?: StringValue;
|
||||
karmaInfo?: NovelKarmaInfo;
|
||||
genres: string[];
|
||||
}
|
||||
|
||||
interface GetNovelResponse {
|
||||
item?: NovelItem;
|
||||
}
|
||||
|
||||
class WuxiaWorld implements Plugin.PluginBase {
|
||||
id = 'wuxiaworld';
|
||||
name = 'Wuxia World';
|
||||
icon = 'src/en/wuxiaworld/icon.png';
|
||||
site = 'https://www.wuxiaworld.com/';
|
||||
filters?: Filters | undefined;
|
||||
version = '0.5.0';
|
||||
|
||||
parseNovels(data: { items: NovelEntry[] }) {
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
data.items.map((novel: NovelEntry) => {
|
||||
const name = novel.name;
|
||||
const cover = novel.coverUrl;
|
||||
const path = `novel/${novel.slug}/`;
|
||||
|
||||
novels.push({
|
||||
name,
|
||||
cover,
|
||||
path,
|
||||
});
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
options: Plugin.PopularNovelsOptions<Filters>,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const link = `${this.site}api/novels`;
|
||||
|
||||
const result = await fetchApi(link);
|
||||
const data = await result.json();
|
||||
|
||||
return this.parseNovels(data);
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const data = await fetchProto<GetNovelResponse>(
|
||||
{
|
||||
proto: this.proto,
|
||||
requestType: 'GetNovelRequest',
|
||||
responseType: 'GetNovelResponse',
|
||||
requestData: { slug: novelPath.split('/')[1] },
|
||||
},
|
||||
'https://api2.wuxiaworld.com/wuxiaworld.api.v2.Novels/GetNovel',
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/grpc-web+proto',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
path: novelPath,
|
||||
name: data.item?.name || 'Untitled',
|
||||
cover: data.item?.coverUrl?.value,
|
||||
summary: parseHTML(
|
||||
data.item?.description?.value + '\n\n' + data.item?.synopsis?.value,
|
||||
).text(),
|
||||
author: data.item?.authorName?.value,
|
||||
genres: data.item?.genres.join(','),
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
const status = data.item?.status;
|
||||
switch (status) {
|
||||
case NovelItem_Status.Active:
|
||||
novel.status = NovelStatus.Ongoing;
|
||||
break;
|
||||
case NovelItem_Status.Hiatus:
|
||||
novel.status = NovelStatus.OnHiatus;
|
||||
break;
|
||||
case NovelItem_Status.All:
|
||||
novel.status = NovelStatus.Unknown;
|
||||
break;
|
||||
default:
|
||||
novel.status = NovelStatus.Completed;
|
||||
}
|
||||
|
||||
// novel.summary = loadedCheerio('.relative > .absolute:first')
|
||||
// .children('span')
|
||||
// .map((i,el) => loadedCheerio(el).text().trim())
|
||||
// .toArray()
|
||||
// .join('\n');
|
||||
|
||||
// novel.genres = loadedCheerio("a.MuiLink-underlineNone")
|
||||
// .map((i,el) => loadedCheerio(el).text())
|
||||
// .toArray()
|
||||
// .join(',');
|
||||
|
||||
// novel.status = loadedCheerio("div.font-set-b10").text();
|
||||
|
||||
const list = await fetchProto<GetChapterListResponse>(
|
||||
{
|
||||
proto: this.proto,
|
||||
requestType: 'GetChapterListRequest',
|
||||
responseType: 'GetChapterListResponse',
|
||||
requestData: { novelId: data.item?.id },
|
||||
},
|
||||
'https://api2.wuxiaworld.com/wuxiaworld.api.v2.Chapters/GetChapterList',
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/grpc-web+proto',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const freeChapter =
|
||||
Number(data.item?.karmaInfo?.maxFreeChapter?.units) +
|
||||
(data.item?.karmaInfo?.maxFreeChapter?.nanos || 0) / 1000000000 || 50;
|
||||
|
||||
const chapter: Plugin.ChapterItem[] = list.items.flatMap(
|
||||
(ChapterGroupItem: ChapterGroupItem) =>
|
||||
ChapterGroupItem.chapterList.map((chapterItem: ChapterItem) => ({
|
||||
page: ChapterGroupItem.title,
|
||||
name:
|
||||
chapterItem.name +
|
||||
(chapterItem.relatedUserInfo?.isChapterUnlocked?.value === false ||
|
||||
(!chapterItem.relatedUserInfo &&
|
||||
Number(chapterItem.number?.units) +
|
||||
(chapterItem.number?.nanos || 0) / 1000000000 >
|
||||
freeChapter)
|
||||
? ' 🔒'
|
||||
: ''),
|
||||
path: novelPath + chapterItem.slug,
|
||||
chapterNumber: chapterItem.offset,
|
||||
releaseTime: new Date(
|
||||
(chapterItem.publishedAt?.seconds || 0) * 1000 +
|
||||
(chapterItem.publishedAt?.nanos || 0) / 1000000,
|
||||
).toISOString(),
|
||||
})),
|
||||
);
|
||||
|
||||
novel.chapters = chapter;
|
||||
|
||||
return novel;
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const paths = chapterPath.split('/');
|
||||
const data = await fetchProto<GetChapterResponse>(
|
||||
{
|
||||
proto: this.proto,
|
||||
requestType: 'GetChapterRequest',
|
||||
responseType: 'GetChapterResponse',
|
||||
requestData: {
|
||||
chapterProperty: {
|
||||
slugs: {
|
||||
novelSlug: paths[1],
|
||||
chapterSlug: paths[2],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'https://api2.wuxiaworld.com/wuxiaworld.api.v2.Chapters/GetChapter',
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/grpc-web+proto',
|
||||
},
|
||||
},
|
||||
);
|
||||
// loadedCheerio(".chapter-nav").remove();
|
||||
// loadedCheerio("#chapter-content > script").remove();
|
||||
// const chapterText = loadedCheerio("#chapter-content").html() || '';
|
||||
|
||||
const chapterText = data.item?.content?.value || '';
|
||||
return chapterText;
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
const searchUrl = 'https://www.wuxiaworld.com/api/novels/search?query=';
|
||||
|
||||
const url = searchUrl + searchTerm;
|
||||
|
||||
const result = await fetchApi(url);
|
||||
const data = await result.json();
|
||||
|
||||
return this.parseNovels(data);
|
||||
}
|
||||
|
||||
proto = `
|
||||
syntax = "proto3";
|
||||
option optimize_for = CODE_SIZE;
|
||||
package wuxiaworld.api.v2;
|
||||
|
||||
import public "google/protobuf/wrappers.proto";
|
||||
import public "google/protobuf/timestamp.proto";
|
||||
|
||||
message StringValue {
|
||||
// The string value.
|
||||
string value = 1;
|
||||
}
|
||||
|
||||
message BoolValue {
|
||||
// The bool value.
|
||||
bool value = 1;
|
||||
}
|
||||
|
||||
message Int32Value {
|
||||
// The int32 value.
|
||||
int32 value = 1;
|
||||
}
|
||||
|
||||
message DecimalValue {
|
||||
// Whole units part of the amount
|
||||
int64 units = 1;
|
||||
// Nano units of the amount (10^-9)
|
||||
// Must be the same sign as units
|
||||
sfixed32 nanos = 2;
|
||||
}
|
||||
|
||||
message Timestamp {
|
||||
int64 seconds = 1;
|
||||
int32 nanos = 2;
|
||||
}
|
||||
|
||||
message RelatedChapterUserInfo {
|
||||
optional BoolValue isChapterUnlocked = 1;
|
||||
optional BoolValue isNovelUnlocked = 2;
|
||||
optional BoolValue isChapterFavorite = 3;
|
||||
optional BoolValue isNovelOwned = 4;
|
||||
optional BoolValue isChapterOwned = 5;
|
||||
}
|
||||
|
||||
message ChapterNovelInfo {
|
||||
int32 id = 1;
|
||||
string name = 2;
|
||||
optional StringValue coverUrl = 3;
|
||||
string slug = 4;
|
||||
}
|
||||
|
||||
message ChapterParagraph {
|
||||
string id = 1;
|
||||
int32 chapterId = 2;
|
||||
int32 totalComments = 3;
|
||||
optional StringValue content = 4;
|
||||
}
|
||||
|
||||
message ChapterItem {
|
||||
int32 entityId = 1;
|
||||
string name = 2;
|
||||
string slug = 3;
|
||||
optional DecimalValue number = 4;
|
||||
optional StringValue content = 5;
|
||||
int32 novelId = 6;
|
||||
bool visible = 7;
|
||||
bool isTeaser = 8;
|
||||
optional Timestamp whenToPublish = 9;
|
||||
bool spoilerTitle = 10;
|
||||
bool allowComments = 11;
|
||||
optional ChapterNovelInfo novelInfo = 14;
|
||||
optional RelatedChapterUserInfo relatedUserInfo = 16;
|
||||
int32 offset = 17;
|
||||
optional Timestamp publishedAt = 18;
|
||||
optional StringValue translatorThoughts = 19;
|
||||
repeated ChapterParagraph paragraphs = 21;
|
||||
}
|
||||
|
||||
message ChapterGroupCounts {
|
||||
int32 total = 1;
|
||||
int32 advance = 2;
|
||||
int32 normal = 3;
|
||||
}
|
||||
|
||||
message ChapterGroupItem {
|
||||
int32 id = 1;
|
||||
string title = 2;
|
||||
int32 order = 3;
|
||||
optional DecimalValue fromChapterNumber = 4;
|
||||
optional DecimalValue toChapterNumber = 5;
|
||||
repeated ChapterItem chapterList = 6;
|
||||
optional ChapterGroupCounts counts = 7;
|
||||
}
|
||||
|
||||
message GetChapterListRequest {
|
||||
int32 novelId = 1;
|
||||
message BaseChapterInfo {
|
||||
oneof chapterInfo {
|
||||
int32 chapterId = 1;
|
||||
string slug = 2;
|
||||
int32 offset = 3;
|
||||
}
|
||||
}
|
||||
message FilterChapters {
|
||||
optional Int32Value chapterGroupId = 1;
|
||||
optional BoolValue isAdvanceChapter = 2;
|
||||
optional BaseChapterInfo baseChapter = 3;
|
||||
}
|
||||
optional FilterChapters filter = 2;
|
||||
optional Int32Value count = 3;
|
||||
}
|
||||
|
||||
message GetChapterListResponse {
|
||||
repeated ChapterGroupItem items = 1;
|
||||
optional ChapterNovelInfo novelInfo = 2;
|
||||
}
|
||||
|
||||
message GetChapterByProperty {
|
||||
message ByNovelAndChapterSlug {
|
||||
string novelSlug = 1;
|
||||
string chapterSlug = 2;
|
||||
}
|
||||
oneof byProperty {
|
||||
int32 chapterId = 1;
|
||||
ByNovelAndChapterSlug slugs = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message GetChapterRequest {
|
||||
optional GetChapterByProperty chapterProperty = 1;
|
||||
}
|
||||
|
||||
message GetChapterResponse {
|
||||
optional ChapterItem item = 1;
|
||||
}
|
||||
|
||||
message NovelKarmaInfo {
|
||||
bool isActive = 1;
|
||||
bool isFree = 2;
|
||||
optional DecimalValue maxFreeChapter = 3;
|
||||
bool canUnlockWithVip = 4;
|
||||
}
|
||||
|
||||
message NovelItem {
|
||||
int32 id = 1;
|
||||
string name = 2;
|
||||
string slug = 3;
|
||||
enum Status {
|
||||
Finished = 0;
|
||||
Active = 1;
|
||||
Hiatus = 2;
|
||||
All = -1;
|
||||
}
|
||||
Status status = 4;
|
||||
bool visible = 7;
|
||||
optional StringValue description = 8;
|
||||
optional StringValue synopsis = 9;
|
||||
optional StringValue coverUrl = 10;
|
||||
optional StringValue translatorName = 11;
|
||||
optional StringValue authorName = 13;
|
||||
optional NovelKarmaInfo karmaInfo = 14;
|
||||
repeated string genres = 16;
|
||||
}
|
||||
|
||||
message GetNovelRequest {
|
||||
oneof selector {
|
||||
int32 id = 1;
|
||||
string slug = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message GetNovelResponse {
|
||||
optional NovelItem item = 1;
|
||||
}
|
||||
|
||||
service Chapters {
|
||||
rpc GetChapterList(GetChapterListRequest) returns (GetChapterListResponse);
|
||||
rpc GetChapter(GetChapterRequest) returns (GetChapterResponse);
|
||||
}
|
||||
|
||||
service Novels {
|
||||
rpc GetNovel(GetNovelRequest) returns (GetNovelResponse);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export default new WuxiaWorld();
|
||||
Reference in New Issue
Block a user