refactor: Overhaul Plugin Architecture & Playground UI (#1846)

* refactor: Move Plugins Out and Consolidate with Multisrc

* feat: Overhaul Playground UI

* feat: Add Filters to New UI

* feat: Persist Selected Plugin

* feat: Add Direct Parse Novel/Chapter Options and Feedback Toasts

[skip ci]

* feat: UX Improvements

* chore: Update Plugin Path in Publish Workflow

* fix: Add Backward Compatibility

* chore: Update Plugin Publishing Script
This commit is contained in:
Rajarshee Chatterjee
2025-10-20 22:54:08 +05:30
committed by GitHub
parent 252beff0bf
commit 20c2b75b01
363 changed files with 6848 additions and 3007 deletions
+201
View File
@@ -0,0 +1,201 @@
import { load as parseHTML } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { Filters, FilterTypes } from '@libs/filterInputs';
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(),
);
const $ = 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) => {
const detailName = $(el).find('h6').text();
const 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): Promise<Plugin.NovelItem[]> {
const result = await fetchApi(this.site + '/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: new URLSearchParams({ q: searchTerm }).toString(),
}).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();
+223
View File
@@ -0,0 +1,223 @@
import { CheerioAPI, load } from 'cheerio';
import { Plugin } from '@/types/plugin';
import { fetchApi } from '@libs/fetch';
import { NovelStatus } from '@libs/novelStatus';
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.3';
async getCheerio(url: string, search: boolean): Promise<CheerioAPI> {
const r = await fetchApi(url);
if (!r.ok && search != true)
throw new Error(
'Could not reach site (' + r.status + ') try to open in webview.',
);
const $ = load(await r.text());
return $;
}
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
const novels: Plugin.NovelItem[] = [];
const body = await fetchApi(this.site + 'browse').then(r => r.text());
const loadedCheerio = load(body);
const postValue = loadedCheerio('div[ax-load][x-data]')
.attr('x-data')
?.replace("browse('", '')
.replace("')", '') as string;
const data = new FormData();
data.append('search', '');
data.append('orderBy', 'recent');
data.append('curpage', pageNo.toString());
data.append('post', postValue);
data.append('action', 'fetch_browse');
const response: any = await fetchApi(this.site + 'ajax', {
body: data,
method: 'POST',
}).then(res => res.json());
response.data.posts.forEach((element: any) =>
novels.push({
name: element.title,
cover: element.thumbnail,
path: element.permalink.replace(this.site, ''),
}),
);
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const $ = await this.getCheerio(this.site + novelPath, false);
const baseUrl = this.site;
const novel: Partial<Plugin.SourceNovel> = {
path: novelPath,
};
novel.name = $('h1').text().trim();
const coverUrl = $('img[x-ref="art"].w-full.rounded.shadow-md').attr('src');
if (coverUrl) {
novel.cover = new URL(coverUrl, baseUrl).href;
}
const genres: string[] = [];
$(
'section[x-data="{ tab: location.hash.substr(1) || \'chapters\' }"].relative > div > div > div.flex.flex-wrap > a',
).each(function () {
genres.push($(this).text().trim());
});
novel.genres = genres.join(', ');
novel.author = $('div.mb-1 a').text().trim();
const rawStatus = $('div.gap-2 span.text-sm').text().trim();
const map: Record<string, string> = {
ongoing: NovelStatus.Ongoing,
hiatus: NovelStatus.OnHiatus,
dropped: NovelStatus.Cancelled,
cancelled: NovelStatus.Cancelled,
completed: NovelStatus.Completed,
};
novel.status = map[rawStatus.toLowerCase()] ?? NovelStatus.Unknown;
const summaryDiv = $('div.mb-4.order-2:not(.lg\\:grid-in-buttons)');
const pTagSummary = summaryDiv.find('p');
novel.summary =
pTagSummary.length > 0
? pTagSummary
.map((_, el) => $(el).text().trim())
.get()
.join('\n\n')
: summaryDiv.text().trim(); // --- Else (no <p> tags) ---
const chapters: Plugin.ChapterItem[] = [];
const xdata = $('.bg-accent div[ax-load][x-data]').attr('x-data');
// expected data format: toc('000000', 'xxxxxxxxxx') (6 numbers, 10 hex)
if (xdata) {
const listXdata = xdata?.split("'");
const dataNovelId = listXdata[1];
const dataNovelN = listXdata[3];
const idMatch = novelPath.match(/\d+/); // Error checking in case of extraneous slashes
const novelId = dataNovelId || (idMatch ? idMatch[0] : null);
if (novelId) {
const chapterListing = 'ajax'; // Currently URL is only ajax
const formData = new FormData();
formData.append('post', dataNovelN);
formData.append('id', dataNovelId);
formData.append('action', 'series_toc');
const chaptersUrl = `${this.site}${chapterListing}`;
const refererUrl = `${this.site}${novel.path}`;
let results = await fetchApi(chaptersUrl, {
method: 'POST',
referrer: refererUrl,
referrerPolicy: 'origin',
body: formData,
})
.then(r => r.json())
.catch(
e =>
(novel.summary =
'Chapter Parse Error: ' + e + '\n\n' + novel.summary),
);
if (results.data) {
results = results.data;
results.forEach(function (chap: any) {
if (chap.url == null) {
return;
}
const name = chap.title;
const url = chap.url as string;
const releaseTime = chap.date;
const chapterNumber = chap.slug;
chapters.push({
name: name,
path: url.replace(baseUrl, ''),
releaseTime,
chapterNumber: parseInt(chapterNumber),
});
});
}
}
}
novel.chapters = chapters;
return novel as Plugin.SourceNovel;
}
async parseChapter(chapterPath: string): Promise<string> {
const $ = await this.getCheerio(this.site + chapterPath, false);
const xdata = $('div[ax-load][x-data]').attr('x-data');
const t = $('div.justify-center > div.mb-4');
let chapterText = t.html() || '';
if (xdata) {
chapterText =
chapterText +
"\n\n Error parsing chapter: Turnstile detected. Advise just reading in web view until there's a fix.";
// const listXdata = xdata?.split("'");
// const dataNovelId = listXdata[1];
// const dataNovelN = listXdata[3];
}
return chapterText;
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
const novels: Plugin.NovelItem[] = [];
const body = await fetchApi(this.site + 'browse').then(r => r.text());
const loadedCheerio = load(body);
const postValue = loadedCheerio('div[ax-load][x-data]')
.attr('x-data')
?.replace("browse('", '')
.replace("')", '') as string;
const 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');
const response: any = await fetchApi(this.site + 'ajax', {
body: data,
method: 'POST',
}).then(res => res.json());
response.data.posts.forEach((element: any) =>
novels.push({
name: element.title,
cover: element.thumbnail,
path: element.permalink.replace(this.site, ''),
}),
);
return novels;
}
}
export default new StorySeedlingPlugin();
+579
View File
@@ -0,0 +1,579 @@
import { CheerioAPI, load as parseHTML } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { Filters, FilterTypes } from '@libs/filterInputs';
import { defaultCover } from '@libs/defaultCover';
class ArchiveOfOurOwn implements Plugin.PluginBase {
id = 'archiveofourown';
name = 'Archive Of Our Own';
version = '1.0.3';
icon = 'src/en/ao3/icon.png';
site = 'https://archiveofourown.org/';
parseNovels(loadedCheerio: CheerioAPI): Plugin.NovelItem[] {
const novels: Plugin.NovelItem[] = [];
loadedCheerio('li.work').each((idx, ele) => {
const novelName = loadedCheerio(ele)
.find('h4.heading > a')
.first()
.text()
.trim();
const novelUrl = loadedCheerio(ele)
.find('h4.heading > a')
.first()
.attr('href')
?.trim();
if (!novelUrl) return;
const novel = {
name: novelName,
cover: defaultCover, // No cover image
path: novelUrl.slice(1),
};
novels.push(novel);
});
return novels;
}
async popularNovels(
page: number,
{ showLatestNovels, filters }: Plugin.PopularNovelsOptions<Filters>,
): Promise<Plugin.NovelItem[]> {
// Base URL and common parameters
let link = `${this.site}works/search?page=${page}&work_search%5Blanguage_id%5D=en`;
// Apply sorting based on showLatestNovels
if (showLatestNovels) {
link += `&work_search%5Bsort_column%5D=revised_at&work_search%5Bsort_direction%5D=${filters.sortdir.value}`;
} else if (filters) {
link += `&work_search%5Bsort_column%5D=${filters.sort.value}&work_search%5Bsort_direction%5D=${filters.sortdir.value}`;
}
// Apply additional filters
if (filters) {
// if (filters.genre.value !== '') link += `&work_search%5Bfandom_names%5D=${filters.genre.value}`;
if (filters.completion.value !== '')
link += `&work_search%5Bcomplete%5D=${filters.completion.value}`;
if (filters.crossover.value !== '')
link += `&work_search%5Bcrossover%5D=${filters.crossover.value}`;
if (filters.categories.value.length > 0) {
filters.categories.value.forEach((category: string) => {
link += `&work_search%5Bcategory_ids%5D%5B%5D=${category}`;
});
}
if (filters.warningsFilter.value.length > 0) {
filters.warningsFilter.value.forEach((warning: string) => {
link += `&work_search%5Barchive_warning_ids%5D%5B%5D=${warning}`;
});
}
if (filters.singlechap.value !== '')
link += `&work_search%5Bsingle_chapter%5D=${filters.singlechap.value}`;
if (filters.author.value !== '')
link += `&work_search%5Bcreators%5D=${filters.author.value}`;
if (
filters.dateFilter.value !== '' &&
filters.dateIncrements.value !== ''
) {
link += `&work_search%5Brevised_at%5D=${filters.dateFilter.value}+${filters.dateIncrements.value}`;
}
if (filters.words.value !== '')
link += `&work_search%5Bword_count%5D=${filters.words.value}`;
if (filters.hits.value !== '')
link += `&work_search%5Bhits%5D=${filters.hits.value}`;
if (filters.bookmarks.value !== '')
link += `&work_search%5Bbookmarks_count%5D=${filters.bookmarks.value}`;
if (filters.comments.value !== '')
link += `&work_search%5Bcomments_count%5D=${filters.comments.value}`;
if (filters.kudos.value !== '')
link += `&work_search%5Bkudos_count%5D=${filters.kudos.value}`;
}
const body = await fetchApi(link).then(r => r.text());
const loadedCheerio = parseHTML(body);
return this.parseNovels(loadedCheerio);
}
async parseNovel(novelUrl: string): Promise<Plugin.SourceNovel> {
const result = await fetchApi(new URL(novelUrl, this.site).toString());
const urlchapter = novelUrl + '/navigate';
const chapters = await fetchApi(new URL(urlchapter, this.site).toString());
const body = await result.text();
const chapterlisttext = await chapters.text();
const chapterlistload = parseHTML(chapterlisttext);
const loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel = {
path: novelUrl,
name: loadedCheerio('h2.title').text().trim() || 'Untitled',
cover: defaultCover, // No cover image available
status: loadedCheerio('dt.status').text().includes('Updated')
? 'Ongoing'
: 'Completed',
chapters: [],
};
novel.author = loadedCheerio('a[rel="author"]')
.map((i, el) => loadedCheerio(el).text().trim())
.get()
.join(', ');
novel.genres = Array.from(loadedCheerio('dd.freeform.tags li a.tag'))
.map(el => loadedCheerio(el).text().trim())
.join(',');
const summary = loadedCheerio('blockquote.userstuff').text().trim();
const fandom = Array.from(loadedCheerio('dd.fandom.tags li a.tag'))
.map(el => loadedCheerio(el).text().trim())
.join(',');
const rating = Array.from(loadedCheerio('dd.rating.tags li a.tag'))
.map(el => loadedCheerio(el).text().trim())
.join(',');
const warning = Array.from(loadedCheerio('dd.warning.tags li a.tag'))
.map(el => loadedCheerio(el).text().trim())
.join(',');
const series = Array.from(loadedCheerio('dd.series li a.tag'))
.map(el => loadedCheerio(el).text().trim())
.join(',');
const relation = Array.from(loadedCheerio('dd.relationship.tags li a.tag'))
.map(el => loadedCheerio(el).text().trim())
.join(',');
const character = Array.from(loadedCheerio('dd.character.tags li a.tag'))
.map(el => loadedCheerio(el).text().trim())
.join(',');
const stats = Array.from(loadedCheerio('dd.stats li a.tag'))
.map(el => loadedCheerio(el).text().trim())
.join(',');
novel.summary = `Fandom:\n${fandom}\n\nRating:\n${rating}\n\nWarning:\n${warning}\n\nSummary:\n${summary}\n\nSeries:\n${series}\n\nRelationships:\n${relation}\n\nCharacters:\n${character}\n\nStats:\n${stats}`;
const chapterItems: Plugin.ChapterItem[] = [];
const longReleaseDate: string[] = [];
let match: RegExpExecArray | null;
chapterlistload('ol.index').each((i, ele) => {
chapterlistload(ele)
.find('li')
.each((i, el) => {
const chapterNameMatch = chapterlistload(el).find('a').text().trim();
const releaseTimeText = chapterlistload(el)
.find('span.datetime')
.text()
.replace(/\(([^)]+)\)/g, '$1')
.trim();
const releaseTime = releaseTimeText
? new Date(releaseTimeText).toISOString()
: '';
longReleaseDate.push(releaseTime);
});
});
const releaseTimeText = loadedCheerio('.wrapper dd.published')
.text()
.trim();
const releaseTime = releaseTimeText
? new Date(releaseTimeText).toISOString()
: '';
let dateCounter = 0;
if (loadedCheerio('#chapter_index select').length > 0) {
loadedCheerio('#chapter_index select').each((i, selectEl) => {
loadedCheerio(selectEl)
.find('option')
.each((i, el) => {
const chapterName = loadedCheerio(el).text().trim();
const chapterUrlCode = loadedCheerio(el).attr('value')?.trim();
const chapterUrl = `${novelUrl}/chapters/${chapterUrlCode}`;
const releaseDate: string = longReleaseDate[dateCounter];
dateCounter++;
if (chapterUrl) {
chapterItems.push({
name: chapterName,
path: chapterUrl,
releaseTime: releaseDate,
});
}
});
});
}
if (chapterItems.length === 0) {
loadedCheerio('#chapters h3.title').each((i, titleEl) => {
const fullTitleText = loadedCheerio(titleEl).text().trim();
const chapterNameMatch = fullTitleText.match(/:\s*(.*)$/);
let chapterName = chapterNameMatch ? chapterNameMatch[1].trim() : '';
const chapterUrlRaw = loadedCheerio(titleEl)
.find('a')
.attr('href')
?.trim();
const chapterUrlCode = chapterUrlRaw?.match(/\/chapters\/(\d+)/)?.[1];
const chapterUrl = `${novelUrl}/chapters/${chapterUrlCode}`;
if (chapterUrl) {
if (chapterName === '') {
const novelTitle = loadedCheerio('.work .title.heading')
.text()
.trim();
chapterName = novelTitle;
}
chapterItems.push({
name: chapterName,
path: chapterUrl,
releaseTime: releaseTime,
});
}
});
if (chapterItems.length === 0) {
loadedCheerio('.work.navigation.actions li a').each((i, el) => {
const href = loadedCheerio(el).attr('href');
if (href && href.includes('/downloads/')) {
const chapterUrlCodeMatch = href.match(/updated_at=(\d+)/);
const chapterUrlCode = chapterUrlCodeMatch
? chapterUrlCodeMatch[1]
: null;
let chapterName = loadedCheerio('h2.title.heading').text().trim();
const chapterUrl = `${novelUrl}/chapters/${chapterUrlCode}`;
if (chapterUrl) {
if (chapterName === '') {
const novelTitle = loadedCheerio('.work .title.heading')
.text()
.trim();
chapterName = novelTitle;
}
chapterItems.push({
name: chapterName,
path: chapterUrl,
releaseTime: releaseTime,
});
}
}
});
}
}
novel.chapters = chapterItems;
return novel;
}
async parseChapter(chapterUrl: string): Promise<string> {
const result = await fetchApi(new URL(chapterUrl, this.site).toString());
const body = await result.text();
const loadedCheerio = parseHTML(body);
loadedCheerio('h3.title').each((i, el) => {
const $h3 = loadedCheerio(el);
const $a = $h3.find('a');
$a.removeAttr('href');
const aText = $a.text().trim();
const nextSiblingText = $h3
.contents()
.filter((_, node) => node.nodeType === 3)
.text()
.trim();
$h3.html(`${aText}<br>${nextSiblingText}`);
});
loadedCheerio('h3.landmark.heading#work').remove();
const chapterText = loadedCheerio('div#chapters > div').html() || '';
return chapterText;
}
async searchNovels(
searchTerm: string,
page: number,
): Promise<Plugin.NovelItem[]> {
const searchUrl = `${this.site}works/search?page=${page}&work_search%5Blanguage_id%5D=en&work_search%5Bquery%5D=${encodeURIComponent(searchTerm)}`;
const result = await fetchApi(searchUrl);
const body = await result.text();
const loadedCheerio = parseHTML(body);
return this.parseNovels(loadedCheerio);
}
filters = {
sort: {
value: 'hits',
label: 'Sort by',
options: [
{ label: 'Best Match', value: '_score' },
{ label: 'Hits', value: 'hits' },
{ label: 'Kudos', value: 'kudos' },
{ label: 'Comments', value: 'comments' },
{ label: 'Bookmarks', value: 'bookmarks' },
{ label: 'Word Count', value: 'word_count' },
{ label: 'Date Updated', value: 'revised_at' },
{ label: 'Date Posted', value: 'created_at' },
{ label: 'Author', value: 'authors_to_sort_on' },
{ label: 'Title', value: 'title_to_sort_on' },
],
type: FilterTypes.Picker,
},
sortdir: {
value: 'desc',
label: 'Sort direction',
options: [
{ label: 'Descending', value: 'desc' },
{ label: 'Ascending', value: 'asc' },
],
type: FilterTypes.Picker,
},
ratings: {
value: '',
label: 'Ratings',
options: [
{ label: 'Not Rated', value: '9' },
{ label: 'General Audiences', value: '10' },
{ label: 'Teen And Up Audiences', value: '11' },
{ label: 'Mature', value: '12' },
{ label: 'Explicit', value: '13' },
],
type: FilterTypes.Picker,
},
language: {
value: 'en',
label: 'Language',
options: [
{ label: 'None', value: '' },
{ label: 'af Soomaali', value: 'so' },
{ label: 'Afrikaans', value: 'afr' },
{ label: 'Aynu itak | アイヌ イタㇰ', value: 'ain' },
{ label: 'العربية', value: 'ar' },
{ label: 'አማርኛ', value: 'amh' },
{ label: '𓂋𓏺𓈖 𓆎𓅓𓏏𓊖', value: 'egy' },
{ label: 'ܐܪܡܝܐ | ארמיא', value: 'arc' },
{ label: 'հայերեն', value: 'hy' },
{ label: 'American Sign Language', value: 'ase' },
{ label: 'asturianu', value: 'ast' },
{ label: 'Bahasa Indonesia', value: 'id' },
{ label: 'Bahasa Malaysia', value: 'ms' },
{ label: 'Български', value: 'bg' },
{ label: 'বাংলা', value: 'bn' },
{ label: 'Basa Jawa', value: 'jv' },
{ label: 'Башҡорт теле', value: 'ba' },
{ label: 'беларуская', value: 'be' },
{ label: 'Bosanski', value: 'bos' },
{ label: 'Brezhoneg', value: 'br' },
{ label: 'Català', value: 'ca' },
{ label: 'Cebuano', value: 'ceb' },
{ label: 'Čeština', value: 'cs' },
{ label: 'Chinuk Wawa', value: 'chn' },
{ label: 'къырымтатар тили | qırımtatar tili', value: 'crh' },
{ label: 'Cymraeg', value: 'cy' },
{ label: 'Dansk', value: 'da' },
{ label: 'Deutsch', value: 'de' },
{ label: 'eesti keel', value: 'et' },
{ label: 'Ελληνικά', value: 'el' },
{ label: '𒅴𒂠', value: 'sux' },
{ label: 'English', value: 'en' },
{ label: 'Eald Englisċ', value: 'ang' },
{ label: 'Español', value: 'es' },
{ label: 'Esperanto', value: 'eo' },
{ label: 'Euskara', value: 'eu' },
{ label: 'فارسی', value: 'fa' },
{ label: 'Filipino', value: 'fil' },
{ label: 'Français', value: 'fr' },
{ label: 'Friisk', value: 'frr' },
{ label: 'Furlan', value: 'fur' },
{ label: 'Gaeilge', value: 'ga' },
{ label: 'Gàidhlig', value: 'gd' },
{ label: 'Galego', value: 'gl' },
{ label: '𐌲𐌿𐍄𐌹𐍃𐌺𐌰', value: 'got' },
{ label: 'Creolese', value: 'gyn' },
{ label: '中文-客家话', value: 'hak' },
{ label: '한국어', value: 'ko' },
{ label: 'Hausa | هَرْشَن هَوْسَ', value: 'hau' },
{ label: 'हिन्दी', value: 'hi' },
{ label: 'Hrvatski', value: 'hr' },
{ label: 'ʻŌlelo Hawaiʻi', value: 'haw' },
{ label: 'Interlingua', value: 'ia' },
{ label: 'isiZulu', value: 'zu' },
{ label: 'Íslenska', value: 'is' },
{ label: 'Italiano', value: 'it' },
{ label: 'עברית', value: 'he' },
{ label: 'Kalaallisut', value: 'kal' },
{ label: 'ಕನ್ನಡ', value: 'kan' },
{ label: 'ქართული', value: 'kat' },
{ label: 'Kernewek', value: 'cor' },
{ label: 'ភាសាខ្មែរ', value: 'khm' },
{ label: 'Khuzdul', value: 'qkz' },
{ label: 'Kiswahili', value: 'sw' },
{ label: 'kreyòl ayisyen', value: 'ht' },
{ label: 'Kurdî | کوردی', value: 'ku' },
{ label: 'Кыргызча', value: 'kir' },
{ label: 'Langue des signes québécoise', value: 'fcs' },
{ label: 'Latviešu valoda', value: 'lv' },
{ label: 'Lëtzebuergesch', value: 'lb' },
{ label: 'Lietuvių kalba', value: 'lt' },
{ label: 'Lingua latina', value: 'la' },
{ label: 'Magyar', value: 'hu' },
{ label: 'македонски', value: 'mk' },
{ label: 'മലയാളം', value: 'ml' },
{ label: 'Malti', value: 'mt' },
{ label: 'ᠮᠠᠨᠵᡠ ᡤᡳᠰᡠᠨ', value: 'mnc' },
{ label: "Mando'a", value: 'qmd' },
{ label: 'मराठी', value: 'mr' },
{ label: 'Mikisúkî', value: 'mik' },
{ label: 'ᠮᠣᠩᠭᠣᠯ ᠪᠢᠴᠢᠭ᠌ | Монгол Кирилл үсэг', value: 'mon' },
{ label: 'မြန်မာဘာသာ', value: 'my' },
{ label: 'Эрзянь кель', value: 'myv' },
{ label: 'Nāhuatl', value: 'nah' },
{ label: '中文-闽南话 臺語', value: 'nan' },
{ label: 'Nawat', value: 'ppl' },
{ label: 'Nederlands', value: 'nl' },
{ label: '日本語', value: 'ja' },
{ label: 'Norsk', value: 'no' },
{ label: 'Азәрбајҹан дили | آذربایجان دیلی', value: 'azj' },
{ label: 'Нохчийн мотт', value: 'ce' },
{ label: '‘O’odham Ñiok', value: 'ood' },
{ label: 'لسان عثمانى', value: 'ota' },
{ label: 'پښتو', value: 'ps' },
{ label: 'Plattdüütsch', value: 'nds' },
{ label: 'Polski', value: 'pl' },
{ label: 'Português brasileiro', value: 'ptBR' },
{ label: 'Português europeu', value: 'ptPT' },
{ label: 'ਪੰਜਾਬੀ', value: 'pa' },
{ label: 'qazaqşa | қазақша', value: 'kaz' },
{ label: 'Uncategorized Constructed Languages', value: 'qlq' },
{ label: 'Quenya', value: 'qya' },
{ label: 'Română', value: 'ro' },
{ label: 'Русский', value: 'ru' },
{ label: 'Scots', value: 'sco' },
{ label: 'Shqip', value: 'sq' },
{ label: 'Sindarin', value: 'sjn' },
{ label: 'සිංහල', value: 'si' },
{ label: 'Slovenčina', value: 'sk' },
{ label: 'Slovenščina', value: 'slv' },
{ label: 'Sprēkō Þiudiskō', value: 'gem' },
{ label: 'Српски', value: 'sr' },
{ label: 'suomi', value: 'fi' },
{ label: 'Svenska', value: 'sv' },
{ label: 'தமிழ்', value: 'ta' },
{ label: 'татар теле', value: 'tat' },
{ label: 'te reo Māori', value: 'mri' },
{ label: 'తెలుగు', value: 'tel' },
{ label: 'ไทย', value: 'th' },
{ label: 'Thermian', value: 'tqx' },
{ label: 'བོད་སྐད་', value: 'bod' },
{ label: 'Tiếng Việt', value: 'vi' },
{ label: 'ϯⲙⲉⲧⲣⲉⲙⲛ̀ⲭⲏⲙⲓ', value: 'cop' },
{ label: 'tlhIngan-Hol', value: 'tlh' },
{ label: 'toki pona', value: 'tok' },
{ label: 'Trinidadian Creole', value: 'trf' },
{ label: 'τσακώνικα', value: 'tsd' },
{ label: 'ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ', value: 'chr' },
{ label: 'Türkçe', value: 'tr' },
{ label: 'Українська', value: 'uk' },
{ label: 'اُردُو', value: 'urd' },
{ label: 'ئۇيغۇر تىلى', value: 'uig' },
{ label: 'Volapük', value: 'vol' },
{ label: '中文-吴语', value: 'wuu' },
{ label: 'יידיש', value: 'yi' },
{ label: 'maayaʼ tʼàan', value: 'yua' },
{ label: '中文-广东话 粵語', value: 'yue' },
{ label: '中文-普通话 國語', value: 'zh' },
],
type: FilterTypes.Picker,
},
completion: {
value: '',
label: 'Completion Status',
options: [
{ label: 'All works', value: 'checked' },
{ label: 'Complete works only', value: 'T' },
{ label: 'Works in progress only', value: 'F' },
],
type: FilterTypes.Picker,
},
crossover: {
value: '',
label: 'Crossover Status',
options: [
{ label: 'Include crossovers', value: 'checked' },
{ label: 'Exclude crossovers', value: 'T' },
{ label: 'Only crossovers', value: 'F' },
],
type: FilterTypes.Picker,
},
categories: {
value: [],
label: 'Categories',
options: [
{ label: 'F/F', value: '116' },
{ label: 'F/M', value: '22' },
{ label: 'Gen', value: '21' },
{ label: 'M/M', value: '23' },
{ label: 'Multi', value: '2246' },
{ label: 'Other', value: '24' },
],
type: FilterTypes.CheckboxGroup,
},
warningsFilter: {
value: [],
label: 'Warnings',
options: [
{ label: 'Creator Chose Not To Use Archive Warnings', value: '14' },
{ label: 'Graphic Depictions Of Violence', value: '17' },
{ label: 'Major Character Death', value: '18' },
{ label: 'No Archive Warnings Apply', value: '16' },
{ label: 'Rape/Non-Con', value: '19' },
{ label: 'Underage', value: '20' },
],
type: FilterTypes.CheckboxGroup,
},
singlechap: {
value: '',
label: 'Single Chapter Stories',
options: [{ label: 'Single Chapter', value: '1' }],
type: FilterTypes.Picker,
},
author: {
value: '',
label: 'Author/Artist',
type: FilterTypes.TextInput,
},
dateFilter: {
value: '',
label: 'Enter single Number only Date',
type: FilterTypes.TextInput,
},
dateIncrements: {
value: 'days+ago',
label: 'Must choose date type',
options: [
{ label: 'Days', value: 'days+ago' },
{ label: 'Weeks', value: 'weeks+ago' },
{ label: 'Months', value: 'months+ago' },
{ label: 'Years', value: 'years+ago' },
],
type: FilterTypes.Picker,
},
words: {
value: '',
label:
'Word Count, exact number eg. 40 or less than eg. <40 or greater than eg. >40 or range eg. 10-100',
type: FilterTypes.TextInput,
},
hits: {
value: '',
label: 'Hits',
type: FilterTypes.TextInput,
},
bookmarks: {
value: '',
label: 'Bookmarks',
type: FilterTypes.TextInput,
},
comments: {
value: '',
label: 'Comments',
type: FilterTypes.TextInput,
},
kudos: {
value: '',
label: 'Kudos',
type: FilterTypes.TextInput,
},
} satisfies Filters;
}
export default new ArchiveOfOurOwn();
+272
View File
@@ -0,0 +1,272 @@
import { CheerioAPI, load as parseHTML } from 'cheerio';
import { Plugin } from '@/types/plugin';
import { defaultCover } from '@libs/defaultCover';
import { fetchApi } 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.1';
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 fetchApi(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 fetchApi(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 fetchApi(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/${encodeURIComponent(searchTerm)}?page=${page}`;
const result = await fetchApi(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();
+156
View File
@@ -0,0 +1,156 @@
import { fetchApi, fetchProto, fetchText } from '@/lib/fetch';
import { Plugin } from '@/types/plugin';
import { Filters } from '@libs/filterInputs';
import { load as loadCheerio } from 'cheerio';
import { defaultCover } from '@libs/defaultCover';
class Chrysanthemumgarden implements Plugin.PluginBase {
id = 'chrysanthemumgarden';
name = 'Chrysanthemum Garden';
icon = 'src/en/chrysanthemumgarden/icon.png';
site = 'https://chrysanthemumgarden.com';
version = '1.0.1';
filters: Filters | undefined = undefined;
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
//flag indicates whether access to LocalStorage, SesesionStorage is required.
webStorageUtilized?: boolean;
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
const req = await fetchApi(
this.site + (pageNo === 1 ? '/books' : '/books/page/' + pageNo) + '/',
);
const body = await req.text();
const loadedCheerio = loadCheerio(body);
return loadedCheerio('article')
.map((i, el) => {
if (
loadedCheerio(el)
.find('div.series-genres > a')
.text()
.includes('Manhua')
)
return;
const href = loadedCheerio(el).find('h2.novel-title > a').attr('href');
if (!href) return;
return {
name: loadedCheerio(el).find('h2.novel-title > a').text(),
path: href
.replace(this.site, '')
.replace(/^\//, '')
.replace(/\/$/, ''),
cover:
loadedCheerio(el)
.find('div.novel-cover > img')
.attr('data-breeze') || defaultCover,
};
})
.toArray()
.filter(Boolean);
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const req = await fetchApi(this.site + '/' + novelPath + '/');
const body = await req.text();
const loadedCheerio = loadCheerio(body);
loadedCheerio('h1.novel-title > span.novel-raw-title').remove();
const novel: Plugin.SourceNovel = {
path: novelPath,
name: loadedCheerio('h1.novel-title').text(),
cover: loadedCheerio('div.novel-cover > img').attr('data-breeze'),
summary: loadedCheerio('div.entry-content > p')
.map((i, el) => loadedCheerio(el).text())
.toArray()
.join('\n\n'),
};
const novelInfoHtml = loadedCheerio('div.novel-info').html();
novel.author = novelInfoHtml
? novelInfoHtml.match(/Author:\s*([^<]*)<br>/)?.[1]?.trim()
: undefined;
novel.genres = [
...loadedCheerio('div.series-genres > a')
.map((i, el) => loadedCheerio(el).text())
.toArray(),
...loadedCheerio('a.series-tag')
.map((i, el) => loadedCheerio(el).text().split('(')[0].trim())
.toArray(),
].join(', ');
novel.chapters = loadedCheerio('div.chapter-item > a')
.map((i, el) => {
const href = loadedCheerio(el).attr('href');
if (!href) return;
return {
name: loadedCheerio(el).text().trim(),
path: href
.replace(this.site, '')
.replace(/^\//, '')
.replace(/\/$/, ''),
};
})
.toArray();
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const req = await fetchApi(this.site + '/' + chapterPath + '/');
const body = await req.text();
const loadedCheerio = loadCheerio(body);
return loadedCheerio('div#novel-content').html() || '';
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
if (pageNo !== 1) return [];
const req = await fetchApi(this.site + '/wp-json/cg/novels');
const body = await req.json();
const allNovels: ChrysanthemumGardenNovelItem[] = (
body as ChrysanthemumGardenNovel[]
).map(
(novel: ChrysanthemumGardenNovel): ChrysanthemumGardenNovelItem => ({
name: novel.name,
path: novel.link
.replace(this.site, '')
.replace(/\/$/, '')
.replace(/^\//, ''),
cover: defaultCover,
}),
);
if (!allNovels) return [];
return allNovels.filter(novel =>
novel.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
}
async getAllNovels() {}
resolveUrl = (path: string, isNovel?: boolean) =>
this.site + (isNovel ? '/book/' : '/chapter/') + path;
}
export default new Chrysanthemumgarden();
type ChrysanthemumGardenNovel = {
name: string;
link: string;
};
type ChrysanthemumGardenNovelItem = {
name: string;
path: string;
cover: string;
};
+301
View File
@@ -0,0 +1,301 @@
import { defaultCover } from '@libs/defaultCover';
import { fetchApi } from '@libs/fetch';
import { Filters, FilterTypes, FilterValueWithType } from '@libs/filterInputs';
import { Plugin } from '@/types/plugin';
import { load as parseHTML } from 'cheerio';
//TODO: this looks similar to fictioneer source? maybe use multisrc someday
class DDLPlugin implements Plugin.PluginBase {
id = 'DDL.com';
name = 'Divine Dao Library';
site = 'https://www.divinedaolibrary.com/';
version = '1.1.1';
icon = 'src/en/divinedaolibrary/icon.png';
filters = {
category: {
type: FilterTypes.CheckboxGroup,
label: 'State',
value: ['Completed', 'Translating', 'Lost in Voting Poll', 'Dropped'],
options: [
{ label: 'Completed', value: 'Completed' },
{ label: 'Translating', value: 'Translating' },
{ label: 'Lost in Voting Poll', value: 'Lost in Voting Poll' },
{ label: 'Dropped', value: 'Dropped' },
{ label: 'Personally Written', value: 'Personally Written' },
],
},
} satisfies Filters;
allNovelsCache: readonly (readonly [string, string, string])[] | undefined;
novelItemCache = new Map<string, Required<Plugin.NovelItem>>();
/**
* Safely extract the pathname from any URL on {@link site}. Check the root
* site as there are novels linking off-site (to Patreon).
*
* @private
*/
getPath(url: string): string | undefined {
if (!url.startsWith(this.site)) {
return undefined;
}
const trimmed = url.substring(this.site.length).replace(/(^\/+|\/+$)/g, '');
if (trimmed.length === 0) {
return undefined;
}
return trimmed;
}
/**
* Map an array with an asynchronous function and only return the array items
* that successfully were fulfilled with values other than undefined.
*
* @private
*/
async asyncMap<T, U>(
collection: readonly T[],
callbackfn: (value: T, index: number, array: readonly T[]) => Promise<U>,
): Promise<Exclude<U, undefined>[]> {
return (await Promise.allSettled(collection.map(callbackfn)))
.filter(
<U>(
p: PromiseSettledResult<U>,
): p is PromiseFulfilledResult<Exclude<U, undefined>> =>
p.status === 'fulfilled' && p.value !== undefined,
)
.map(({ value }) => value);
}
/**
* DDL links to future (unpublished) chapters from its novel pages. To be
* able to report updates correctly, try to figure out which chapter was the
* actual latest one to be published.
*
* @private
* @returns the path value of the latest published chapter (or undefined)
*/
async findLatestChapter(novelPath: string): Promise<string | undefined> {
const link = `${this.site}wp-json/wp/v2/categories?slug=${novelPath}`;
const guessCategory = await fetchApi(link).then(res => res.json());
if (guessCategory.length !== 1) {
return undefined;
}
const categoryId = guessCategory[0].id;
const chapterLink = `${this.site}wp-json/wp/v2/posts?categories=${categoryId}&per_page=1`;
const lastChapter = await fetchApi(chapterLink).then(res => res.json());
if (lastChapter.length !== 1) {
return undefined;
}
return lastChapter[0].slug;
}
/**
* Based on the path, grab all the available information about a novel. Used
* to seed the {@link novelItemCache} as well as to fetch the actual single
* novel views with chapter list.
*
* @private
*/
async grabNovel(
path: string,
getChapters = false,
): Promise<(Plugin.SourceNovel & Required<Plugin.NovelItem>) | undefined> {
const link = `${this.site}wp-json/wp/v2/pages?slug=${path}`;
const data = await fetchApi(link).then(res => res.json());
if (data.length !== 1) {
return undefined;
}
const content = parseHTML(data[0].content.rendered);
const excerpt = parseHTML(data[0].excerpt.rendered);
const image = content('img').first();
let chapters: Plugin.ChapterItem[] = [];
if (getChapters) {
const linkedChapters = content('li > span > a')
.map((_, anchorEl) => {
const chapterPath = this.getPath(anchorEl.attribs['href']);
if (!chapterPath) return;
return {
name: content(anchorEl).text(),
path: chapterPath,
} satisfies Plugin.ChapterItem;
})
.toArray();
const lastChapterPath = await this.findLatestChapter(path);
if (lastChapterPath) {
chapters = linkedChapters.slice(
0,
1 +
linkedChapters.findIndex(
chapter => chapter.path === lastChapterPath,
),
);
} else {
chapters = linkedChapters;
}
}
return {
name: data[0].title.rendered,
path,
cover: image.attr('data-lazy-src') ?? image.attr('src') ?? defaultCover,
author: content('h3')
.first()
.text()
.replace(/^Author:\s*/g, ''),
summary: excerpt('p')
.first()
.text()
.replace(/^.+Description\s*/g, ''),
chapters,
};
}
/**
* Based on the path, grab the {@link Plugin.NovelItem} information from the
* cache. If not available in the cache, it will fetch the information.
*
* @private
*/
async grabCachedNovel(path: string): Promise<Plugin.NovelItem | undefined> {
const fromCache = this.novelItemCache.get(path);
if (fromCache) {
return fromCache;
}
const sourceNovel = await this.grabNovel(path, false);
if (sourceNovel === undefined) {
return undefined;
}
const novel = {
name: sourceNovel.name,
path: sourceNovel.path,
cover: sourceNovel.cover,
};
this.novelItemCache.set(path, novel);
return novel;
}
/**
* Get the list of all novels listed on the novels page. Cache it so filters
* do not have to refetch the same HTML again.
*
* @private
*/
async grabCachedNovels(): Promise<
readonly (readonly [string, string, string])[] | undefined
> {
if (this.allNovelsCache) {
return this.allNovelsCache;
}
const body = await fetchApi(this.site + 'novels').then(res => res.text());
const loadedCheerio = parseHTML(body);
const novels = loadedCheerio('.entry-content ul')
.map((_, listEl) => {
const list = loadedCheerio(listEl);
const category = list.prev().text();
return list
.find('a')
.map((_, anchorEl) => {
const path = this.getPath(anchorEl.attribs['href']);
if (!path) return;
const name = loadedCheerio(anchorEl).text();
return [[category, name, path]] as const;
})
.toArray();
})
.toArray();
this.allNovelsCache = novels;
return novels;
}
/**
* Parse list of paths of recently updated novels from the homepage.
*
* @private
*/
async latestNovels(): Promise<readonly string[]> {
const body = await fetchApi(this.site).then(res => res.text());
const loadedCheerio = parseHTML(body);
const novelPaths = loadedCheerio('#main')
.find('a[rel="category tag"]')
.map((_, anchorEl) => {
const path = this.getPath(anchorEl.attribs['href']);
return path;
})
.toArray();
const uniqueNovelPaths = new Set(novelPaths);
return Array.from(uniqueNovelPaths);
}
/**
* Parse list of paths from novels list, optionally filtered.
*
* @private
*/
async allNovels(
categoryFilter: FilterValueWithType<FilterTypes.CheckboxGroup>,
) {
const allNovels = await this.grabCachedNovels();
if (!allNovels) return [];
return allNovels
.filter(([category]) => categoryFilter.value.includes(category))
.map(([, , path]) => path);
}
async popularNovels(
pageNo: number,
options: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
if (pageNo !== 1) {
return [];
}
const novelsList = options.showLatestNovels
? this.latestNovels()
: this.allNovels(options.filters.category);
return await this.asyncMap(
await novelsList,
this.grabCachedNovel.bind(this),
);
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const sourceNovel = await this.grabNovel(novelPath, true);
if (sourceNovel === undefined) {
throw new Error(`The path "${novelPath}" could not be resolved.`);
}
return sourceNovel;
}
async parseChapter(chapterPath: string): Promise<string> {
const chapterLink = `${this.site}wp-json/wp/v2/posts?slug=${chapterPath}`;
const chapter = await fetchApi(chapterLink).then(res => res.json());
if (chapter.length !== 1) {
return '';
}
const title = `<h1>${chapter[0].title.rendered}</h1>`;
const content = chapter[0].content.rendered;
return `${title}${content}`;
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
if (pageNo !== 1) {
return [];
}
const allNovels = await this.grabCachedNovels();
if (!allNovels) return [];
const foundNovels = allNovels
.filter(([, name]) =>
name.toLocaleLowerCase().includes(searchTerm.toLocaleLowerCase()),
)
.map(([, , path]) => path);
return await this.asyncMap(
await foundNovels,
this.grabCachedNovel.bind(this),
);
}
}
export default new DDLPlugin();
+188
View File
@@ -0,0 +1,188 @@
import { CheerioAPI, load as parseHTML } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
class DreamBigTL implements Plugin.PluginBase {
id = 'dreambigtl';
name = 'Dream Big Translations';
version = '1.0.0';
site = 'https://www.dreambigtl.com/';
icon = 'src/en/dreambigtl/icon.png';
async popularNovels(
pageNo: number,
{ showLatestNovels }: Plugin.PopularNovelsOptions,
): Promise<Plugin.NovelItem[]> {
const url = `${this.site}p/disclaimer.html`;
const response = await fetchApi(url);
const body = await response.text();
const loadedCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
const categories = ['New Novels', 'Ongoing Novels', 'Completed Novels'];
loadedCheerio('#webify-pro-main-nav-menu > li.has-sub').each(
(_, categoryElem) => {
const categoryName = loadedCheerio(categoryElem)
.children('a')
.first()
.text()
.trim();
if (categories.includes(categoryName)) {
const subMenu = loadedCheerio(categoryElem).find('ul.sub-menu.m-sub');
subMenu.find('li > a').each((_, novelElem) => {
const novelName = loadedCheerio(novelElem).text().trim();
const novelUrl = loadedCheerio(novelElem).attr('href');
if (novelUrl) {
novels.push({
name: novelName,
path: novelUrl.replace(this.site, ''),
cover: '',
});
}
});
}
},
);
novels.forEach(novel => console.log('Novel:', novel.name, novel.path));
if (novels.length === 0) {
// Fallback: try to parse everything...
loadedCheerio('a').each((_, elem) => {
const href = loadedCheerio(elem).attr('href');
if (href && href.includes('/p/') && !href.includes('disclaimer')) {
const novelName = loadedCheerio(elem).text().trim();
novels.push({
name: novelName,
path: href.replace(this.site, ''),
cover: '',
});
}
});
}
if (showLatestNovels) {
novels.sort((a, b) => b.path.localeCompare(a.path));
}
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const url = this.site + novelPath;
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel = {
path: novelPath,
name: loadedCheerio('h1.entry-title').text().trim(),
cover: loadedCheerio('.post-body img').first().attr('src') || '',
summary: loadedCheerio('.post-body p').first().text().trim(),
author:
loadedCheerio('.post-body')
.text()
.match(/Author:\s*(.+)/i)?.[1]
?.trim() || 'Unknown',
status: this.getNovelStatus(novelPath),
chapters: await this.parseChapters(loadedCheerio),
};
return novel;
}
async parseChapters(
loadedCheerio: CheerioAPI,
): Promise<Plugin.ChapterItem[]> {
const chapters: Plugin.ChapterItem[] = [];
// Parse "Free Tier" chapters
loadedCheerio('.chapter-panel').each((_, panel) => {
const panelTitle = loadedCheerio(panel).find('summary').text().trim();
if (panelTitle === 'Free Tier') {
loadedCheerio(panel)
.find('ul li a')
.each((_, chapterEle) => {
const chapterName = loadedCheerio(chapterEle).text().trim();
const chapterUrl = loadedCheerio(chapterEle).attr('href');
if (chapterUrl) {
chapters.push({
name: chapterName,
path: chapterUrl.replace(this.site, ''),
});
}
});
}
});
// Parse "List of Chapters"
if (chapters.length === 0) {
loadedCheerio(
'h2:contains("List of Chapters"), span:contains("List of Chapters")',
).each((_, header) => {
const chapterList = loadedCheerio(header).next('ul');
chapterList.find('li a').each((_, chapterEle) => {
const chapterName = loadedCheerio(chapterEle).text().trim();
const chapterUrl = loadedCheerio(chapterEle).attr('href');
if (chapterUrl) {
chapters.push({
name: chapterName,
path: chapterUrl.replace(this.site, ''),
});
}
});
});
}
return chapters.reverse();
}
getNovelStatus(novelPath: string): string {
if (novelPath.includes('completed')) return 'Completed';
return 'Ongoing';
}
async parseChapter(chapterPath: string): Promise<string> {
const url = this.site + chapterPath;
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = parseHTML(body);
const chapterTitle = loadedCheerio('h1.entry-title').text().trim();
const chapterContent = loadedCheerio('.post-body').html() || '';
return `<h1>${chapterTitle}</h1>${chapterContent}`;
}
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
const url = `${this.site}search?q=${encodeURIComponent(searchTerm)}`;
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
loadedCheerio(
'.blog-posts.index-post-wrap .blog-post.hentry.index-post',
).each((_, ele) => {
const novelName = loadedCheerio(ele).find('.entry-title a').text().trim();
const novelUrl = loadedCheerio(ele).find('.entry-title a').attr('href');
const novelCover =
loadedCheerio(ele).find('.entry-image').attr('data-image') || '';
if (novelUrl) {
novels.push({
name: novelName,
path: novelUrl.replace(this.site, ''),
cover: novelCover,
});
}
});
return novels;
}
}
export default new DreamBigTL();
+199
View File
@@ -0,0 +1,199 @@
import { CheerioAPI, load as parseHTML } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { FilterTypes, Filters } from '@libs/filterInputs';
import { Plugin } from '@/types/plugin';
class EarlyNovelPlugin implements Plugin.PagePlugin {
id = 'earlynovel';
name = 'Early Novel';
version = '1.0.1';
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();
const 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 () {
const detailName = loadedCheerio(this).text();
const 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): Promise<Plugin.NovelItem[]> {
const url = `${this.site}search?keyword=${encodeURIComponent(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();
+188
View File
@@ -0,0 +1,188 @@
import { Plugin } from '@/types/plugin';
import { fetchApi } 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 = '2.0.1';
icon = 'src/en/faqwikius/icon.png';
parseNovels(loadedCheerio: CheerioAPI, searchTerm?: string) {
let novels: Plugin.NovelItem[] = [];
loadedCheerio('.plt-page-item').each((index, element) => {
const name = loadedCheerio(element)
.text()
.replace('Novel – All Chapters', '')
.trim();
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:')
?.replace(this.site, '')
?.replace(/\/+$/, '');
if (!path) return;
novels.push({ name, cover, path });
});
if (searchTerm) {
novels = novels.filter(novel =>
novel.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
}
return novels;
}
async popularNovels(): 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();
const img = loadedCheerio('.wp-block-image img');
const cover = img.attr('data-ezsrc') || img.attr('src');
novel.cover = cover?.replace(/\?ezimgfmt=.*$/, ''); // Regular expression magic!
const status = loadedCheerio(
"#lcp_instance_0 +:icontains('complete')",
).text();
novel.status = status ? NovelStatus.Completed : NovelStatus.Ongoing;
loadedCheerio('.entry-content strong').each((i, el) => {
const key = loadedCheerio(el).text().trim().toLowerCase();
const parent = loadedCheerio(el).parent();
const values = [parent.text().slice(key.length).trim()].concat(
parent
.nextUntil('p:has(strong)')
.map((e, ax) => loadedCheerio(ax).text().trim())
.get(),
);
let genreText = values.join(' ').trim();
const multiWordGenres = [
//add more when found
'Slice of Life',
'School Life',
];
multiWordGenres.forEach(genre => {
genreText = genreText.replace(
new RegExp(`\\b${genre}\\b`, 'g'),
genre.replace(/ /g, '_'),
);
});
const genres = genreText
.split(/\s+/)
.map(word => word.replace(/_/g, ' '))
.join(', ');
switch (key) {
case 'description:':
novel.summary = values.join('\n');
break;
case 'author(s):':
novel.author = values[0];
break;
case 'genre:':
novel.genres = genres;
}
});
const chapters: Plugin.ChapterItem[] = [];
loadedCheerio('.lcp_catlist li a').each((chapterIndex, element) => {
const name = loadedCheerio(element)
.text()
.replace(novel.name + '', '')
.replace('Novel' + '', '')
.trim();
const path = loadedCheerio(element)
.attr('href')
?.replace(this.site, '')
?.replace(/\/+$/, '');
const chapterNumber = chapterIndex + 1;
if (!path) return;
chapters.push({
name,
path,
chapterNumber,
});
});
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 removal = ['.entry-content span', '.entry-content div', 'script'];
removal.map(e => {
loadedCheerio(e).remove();
});
const chapterText = loadedCheerio('.entry-content').html()!;
// const chapterParagraphs = loadedCheerio('.entry-content p');
// let chapterContent; // Save this code in case needed
// 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 chapterText;
}
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();
+257
View File
@@ -0,0 +1,257 @@
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { load as loadCheerio } from 'cheerio';
import { Filters, FilterTypes } from '@libs/filterInputs';
import { storage } from '@/lib/storage';
import { defaultCover } from '@libs/defaultCover';
class FenrirRealmPlugin implements Plugin.PluginBase {
id = 'fenrir';
name = 'Fenrir Realm';
icon = 'src/en/fenrirrealm/icon.png';
site = 'https://fenrirealm.com';
version = '1.0.10';
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
hideLocked = storage.get('hideLocked');
pluginSettings = {
hideLocked: {
value: '',
label: 'Hide locked chapters',
type: 'Switch',
},
};
//flag indicates whether access to LocalStorage, SesesionStorage is required.
webStorageUtilized?: boolean;
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
// let sort = "updated";
let sort = filters.sort.value;
if (showLatestNovels) sort = 'latest';
const genresFilter = filters.genres.value
.map(g => '&genres%5B%5D=' + g)
.join('');
const res = await fetchApi(
`${this.site}/api/series/filter?page=${pageNo}&per_page=20&status=${filters.status.value}&order=${sort}${genresFilter}`,
).then(r => r.json());
return res.data.map(r => this.parseNovelFromApi(r));
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const html = await fetchApi(`${this.site}/series/${novelPath}`, {
headers: {
'User-Agent': '',
},
}).then(r => r.text());
const loadedCheerio = loadCheerio(html);
const novel: Plugin.SourceNovel = {
path: novelPath,
name: loadedCheerio('h1.my-2').text(),
summary: loadedCheerio(
'div.overflow-hidden.transition-all.max-h-\\[108px\\] p',
)
.map((i, el) => loadCheerio(el).text())
.get()
.join('\n\n'),
};
// novel.artist = '';
novel.author = loadedCheerio(
'div.flex-1 > div.mb-3 > a.inline-flex',
).text();
const coverMatch = html.match(/,cover:"storage\/(.+?)",cover_data_url/);
novel.cover = coverMatch
? this.site + '/storage/' + coverMatch[1]
: defaultCover;
novel.genres = loadedCheerio('div.flex-1 > div.flex:not(.mb-3, .mt-5) > a')
.map((i, el) => loadCheerio(el).text())
.toArray()
.join(',');
novel.status = loadedCheerio('div.flex-1 > div.mb-3 > span.rounded-md')
.first()
.text();
let chapters = await fetchApi(
this.site + '/api/novels/chapter-list/' + novelPath,
).then(r => r.json());
if (this.hideLocked) {
chapters = chapters.filter(c => !c.locked?.price);
}
novel.chapters = chapters
.map(c => ({
name:
(c.locked?.price ? '🔒 ' : '') +
(c.group?.index === null ? '' : 'Vol ' + c.group.index + ' ') +
'Chapter ' +
c.number +
(c.title && c.title.trim() != 'Chapter ' + c.number
? ' - ' + c.title.replace(/^chapter [0-9]+ . /i, '')
: ''),
path:
novelPath +
(c.group?.index === null ? '' : '/' + c.group.slug) +
'/chapter-' +
c.number,
releaseTime: c.created_at,
chapterNumber: c.number + c.group?.index * 1000000000000,
}))
.sort((a, b) => a.chapterNumber - b.chapterNumber);
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const page = await fetchApi(this.site + '/series/' + chapterPath, {
headers: {
'User-Agent': '',
},
}).then(r => r.text());
return loadCheerio(page)('#reader-area').html() || '';
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
return await fetchApi(
`${this.site}/api/series/filter?page=${pageNo}&per_page=20&search=${encodeURIComponent(searchTerm)}`,
)
.then(r => r.json())
.then(r => r.data.map(novel => this.parseNovelFromApi(novel)));
}
parseNovelFromApi(apiData) {
return {
name: apiData.title,
path: apiData.slug,
cover: this.site + '/' + apiData.cover,
summary: apiData.description,
status: apiData.status,
genres: apiData.genres.map(g => g.name).join(','),
};
}
resolveUrl = (path: string, isNovel?: boolean) =>
this.site + '/series/' + path;
filters = {
status: {
type: FilterTypes.Picker,
label: 'Status',
value: 'any',
options: [
{ label: 'All', value: 'any' },
{ label: 'Ongoing', value: 'ongoing' },
{
label: 'Completed',
value: 'completed',
},
],
},
sort: {
type: FilterTypes.Picker,
label: 'Sort',
value: 'popular',
options: [
{ label: 'Popular', value: 'popular' },
{ label: 'Latest', value: 'latest' },
{ label: 'Updated', value: 'updated' },
],
},
genres: {
type: FilterTypes.CheckboxGroup,
label: 'Genres',
value: [],
options: [
{ 'label': 'Action', 'value': '1' },
{ 'label': 'Adult', 'value': '2' },
{
'label': 'Adventure',
'value': '3',
},
{ 'label': 'Comedy', 'value': '4' },
{ 'label': 'Drama', 'value': '5' },
{
'label': 'Ecchi',
'value': '6',
},
{ 'label': 'Fantasy', 'value': '7' },
{ 'label': 'Gender Bender', 'value': '8' },
{
'label': 'Harem',
'value': '9',
},
{ 'label': 'Historical', 'value': '10' },
{ 'label': 'Horror', 'value': '11' },
{
'label': 'Josei',
'value': '12',
},
{ 'label': 'Martial Arts', 'value': '13' },
{ 'label': 'Mature', 'value': '14' },
{
'label': 'Mecha',
'value': '15',
},
{ 'label': 'Mystery', 'value': '16' },
{ 'label': 'Psychological', 'value': '17' },
{
'label': 'Romance',
'value': '18',
},
{ 'label': 'School Life', 'value': '19' },
{ 'label': 'Sci-fi', 'value': '20' },
{
'label': 'Seinen',
'value': '21',
},
{ 'label': 'Shoujo', 'value': '22' },
{ 'label': 'Shoujo Ai', 'value': '23' },
{
'label': 'Shounen',
'value': '24',
},
{ 'label': 'Shounen Ai', 'value': '25' },
{ 'label': 'Slice of Life', 'value': '26' },
{
'label': 'Smut',
'value': '27',
},
{ 'label': 'Sports', 'value': '28' },
{ 'label': 'Supernatural', 'value': '29' },
{
'label': 'Tragedy',
'value': '30',
},
{ 'label': 'Wuxia', 'value': '31' },
{ 'label': 'Xianxia', 'value': '32' },
{
'label': 'Xuanhuan',
'value': '33',
},
{ 'label': 'Yaoi', 'value': '34' },
{ 'label': 'Yuri', 'value': '35' },
],
},
} satisfies Filters;
}
export default new FenrirRealmPlugin();
//paste into console on site to load
async function getUpdatedGenres() {
const data = await fetch(
'https://fenrirealm.com/api/novels/taxonomy/genres',
).then(d => d.json());
const genreData = data.map(g => ({ label: g.name, value: g.id.toString() }));
console.log(JSON.stringify(genreData));
}
+201
View File
@@ -0,0 +1,201 @@
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { Filters } from '@libs/filterInputs';
import { load as loadCheerio } from 'cheerio';
import { NovelStatus } from '@libs/novelStatus';
import dayjs from 'dayjs';
class FictionZonePlugin implements Plugin.PagePlugin {
id = 'fictionzone';
name = 'Fiction Zone';
icon = 'src/en/fictionzone/icon.png';
site = 'https://fictionzone.net';
version = '1.0.1';
filters: Filters | undefined = undefined;
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
//flag indicates whether access to LocalStorage, SesesionStorage is required.
webStorageUtilized?: boolean;
cachedNovelIds = new Map<string, string>();
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
return await this.getPage(this.site + '/library?page=' + pageNo);
}
async getPage(url: string) {
const req = await fetchApi(url);
const body = await req.text();
const loadedCheerio = loadCheerio(body);
return loadedCheerio('div.novel-card')
.map((i, el) => {
const novelName = loadedCheerio(el).find('a > div.title > h1').text();
const novelCover = loadedCheerio(el).find('img').attr('src');
const novelUrl = loadedCheerio(el).find('a').attr('href');
return {
name: novelName,
cover: novelCover,
path: novelUrl!.replace(/^\//, '').replace(/\/$/, ''),
};
})
.toArray();
}
async parseNovel(
novelPath: string,
): Promise<Plugin.SourceNovel & { totalPages: number }> {
const req = await fetchApi(this.site + '/' + novelPath);
const body = await req.text();
const loadedCheerio = loadCheerio(body);
const novel: Plugin.SourceNovel & { totalPages: number } = {
path: novelPath,
name: loadedCheerio('div.novel-title > h1').text(),
totalPages: 1,
};
// novel.artist = '';
novel.author = loadedCheerio('div.novel-author > content').text();
novel.cover = loadedCheerio('div.novel-img > img').attr('src');
novel.genres = [
...loadedCheerio('div.genres > .items > span')
.map((i, el) => loadedCheerio(el).text())
.toArray(),
...loadedCheerio('div.tags > .items > a')
.map((i, el) => loadedCheerio(el).text())
.toArray(),
].join(',');
const status = loadedCheerio('div.novel-status > div.content')
.text()
.trim();
if (status === 'Ongoing') novel.status = NovelStatus.Ongoing;
novel.summary = loadedCheerio('#synopsis > div.content').text();
const nuxtData = loadedCheerio('script#__NUXT_DATA__').html();
const parsed = JSON.parse(nuxtData!);
let last = null;
for (const a of parsed) {
if (typeof a === 'string' && a.startsWith('novel_covers/')) break;
last = a;
}
this.cachedNovelIds.set(novelPath, last.toString());
// @ts-ignore
novel.chapters = loadedCheerio(
'div.chapters > div.list-wrapper > div.items > a.chapter',
)
.map((i, el) => {
const chapterName = loadedCheerio(el).find('span.chapter-title').text();
const chapterUrl = loadedCheerio(el)
.attr('href')
?.replace(/^\//, '')
.replace(/\/$/, '');
const uploadTime = this.parseAgoDate(
loadedCheerio(el).find('span.update-date').text(),
);
return {
name: chapterName,
releaseTime: uploadTime,
path: chapterUrl?.replace(/^\//, '').replace(/\/$/, ''),
};
})
.toArray()
.filter(chap => !!chap.path);
novel.totalPages = parseInt(
loadedCheerio('div.chapters ul.el-pager > li:last-child').text(),
);
return novel;
}
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
let id = this.cachedNovelIds.get(novelPath);
if (!id) {
await this.parseNovel(novelPath);
id = this.cachedNovelIds.get(novelPath);
}
const data = await fetchApi(this.site + '/api/__api_party/api-v1', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
'path': '/chapter/all/' + id,
'query': { 'page': parseInt(page) },
'headers': { 'content-type': 'application/json' },
'method': 'get',
}),
}).then(r => r.json());
return {
chapters: data._data.map((c: any) => ({
name: c.title,
releaseTime: new Date(c.created_at).toISOString(),
path: novelPath + '/' + c.slug,
})),
};
}
async parseChapter(chapterPath: string): Promise<string> {
const req = await fetchApi(this.site + '/' + chapterPath);
const body = await req.text();
const loadedCheerio = loadCheerio(body);
return loadedCheerio('div.chapter-content').html() || '';
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
return await this.getPage(
this.site +
'/library?query=' +
encodeURIComponent(searchTerm) +
'&page=' +
pageNo +
'&sort=views-all',
);
}
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
}
resolveUrl = (path: string, isNovel?: boolean) => this.site + '/' + path;
}
export default new FictionZonePlugin();
+331
View File
@@ -0,0 +1,331 @@
import { Plugin } from '@/types/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.2';
icon = 'src/en/foxteller/icon.png';
async safeFecth(url: string, init: any = {}): Promise<string> {
const r = await fetchApi(url, init);
if (!r.ok)
throw new Error(
'Could not reach site (' + r.status + ') try to open in webview.',
);
const data = await r.text();
const title = data.match(/<title>(.*?)<\/title>/)?.[1]?.trim();
if (
title &&
(title == 'Bot Verification' ||
title == 'You are being redirected...' ||
title == 'Un instant...' ||
title == 'Just a moment...' ||
title == 'Redirecting...')
)
throw new Error('Captcha error, please open in webview');
return data;
}
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 this.safeFecth(url);
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 this.safeFecth(this.resolveUrl(novelPath));
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 this.safeFecth(this.resolveUrl(chapterPath));
const novelID = chapterPath.split('/')[0];
const chapterID = res.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: this.resolveUrl(chapterPath),
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(decodeBase64(base64));
}
throw new Error('This chapter is closed');
}
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
const body = await this.safeFecth(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 }),
});
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) => 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) {
const hexcode = [];
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);
const chr2 = ((enc2 & 15) << 4) | (enc3 >> 2);
const chr3 = ((enc3 & 3) << 6) | enc4;
hexcode.push(chr1.toString(16).padStart(2, '0'));
if (enc3 !== 64) {
hexcode.push(chr2.toString(16).padStart(2, '0'));
}
if (enc4 !== 64) {
hexcode.push(chr3.toString(16).padStart(2, '0'));
}
}
return '%' + hexcode.join('%');
}
+512
View File
@@ -0,0 +1,512 @@
import { fetchApi } from '@libs/fetch';
import { Filters, FilterTypes } from '@libs/filterInputs';
import { Plugin } from '@/types/plugin';
/**
* Example for novel API:
* https://genesistudio.com/novels/dlh/__data.json?x-sveltekit-invalidated=001
* -> to view novel remove '__data.json?x-sveltekit-invalidated=001'
*
* Example for chapter API:
* https://genesistudio.com/viewer/2443/__data.json?x-sveltekit-invalidated=001
* -> to view chapter remove '__data.json?x-sveltekit-invalidated=001'
*/
class Genesis implements Plugin.PluginBase {
id = 'genesistudio';
name = 'Genesis';
icon = 'src/en/genesis/icon.png';
customCSS = 'src/en/genesis/customCSS.css';
site = 'https://genesistudio.com';
version = '1.1.2';
imageRequestInit?: Plugin.ImageRequestInit | undefined = {
headers: {
'referrer': this.site,
},
};
async parseNovels(json: any[]): Promise<Plugin.SourceNovel[]> {
return json.map((novel: any) => ({
name: novel.novel_title,
path: `/novels/${novel.abbreviation}`,
cover: novel.cover,
}));
}
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
if (pageNo !== 1) return [];
let link = `${this.site}/api/novels/search?`;
if (showLatestNovels) {
link += 'serialization=All&sort=Recent';
} else {
if (filters?.genres.value.length) {
link += 'genres=' + filters.genres.value.join('&genres=') + '&';
}
if (filters?.storyStatus.value) {
link += 'serialization=' + `${filters.storyStatus.value}` + '&';
}
if (filters?.sort.value) {
link += 'sort=' + `${filters.sort.value}`;
}
}
const json = await fetchApi(link).then(r => r.json());
return this.parseNovels(json);
}
// Helper function to extract novel data from nodes
extractData(nodes: any[]): any {
return nodes
.filter((node: { type: string }) => node.type === 'data')
.map((node: { data: any }) => node.data)[0];
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const url = `${this.site}${novelPath}/__data.json?x-sveltekit-invalidated=001`;
// Fetch the novel's data in JSON format
const json = await fetchApi(url).then(r => r.json());
const nodes = json.nodes;
// Extract the main novel data from the nodes
const data = this.extractData(nodes);
// Initialize the novel object with default values
const novel: Plugin.SourceNovel = {
path: novelPath,
name: '',
cover: '',
summary: '',
author: '',
status: 'Unknown',
chapters: [],
};
// Parse and assign novel metadata (title, cover, summary, author, etc.)
this.populateNovelMetadata(novel, data);
// Parse the chapters if available and assign them to the novel object
novel.chapters = this.extractChapters(data);
return novel;
}
// Helper function to populate novel metadata
populateNovelMetadata(novel: Plugin.SourceNovel, data: any): void {
for (const key in data) {
const value = data[key];
if (
typeof value === 'object' &&
value !== null &&
'novel_title' in value
) {
novel.name = data[value.novel_title] || 'Unknown Title';
novel.cover = data[value.cover] || '';
novel.summary = data[value.synopsis] || '';
novel.author = data[value.author] || 'Unknown Author';
novel.genres =
(data[value.genres] as number[])
.map((genreId: number) => data[genreId])
.join(', ') || 'Unknown Genre';
novel.status = value.release_days ? 'Ongoing' : 'Completed';
break; // Break the loop once metadata is found
}
}
}
// Helper function to extract and format chapters
extractChapters(data: any): Plugin.ChapterItem[] {
const chapterKey = 'chapters';
const chapters: Plugin.ChapterItem[] = [];
// Iterate over each property in data to find chapter containers
for (const key in data) {
const value = data[key];
// Look for an object with a 'chapters' key
if (value && typeof value === 'object' && chapterKey in value) {
const chaptersIndexKey = value[chapterKey];
const chapterData = data[chaptersIndexKey];
if (!chapterData || typeof chapterData !== 'object') continue;
// Dynamically get chapter indices from every category (free, premium, etc.)
let allChapterIndices: number[] = [];
for (const category of Object.keys(chapterData)) {
const chapterIndices = data[chapterData[category]];
if (Array.isArray(chapterIndices)) {
allChapterIndices = allChapterIndices.concat(chapterIndices);
}
}
// Format each chapter and add only valid ones
const formattedChapters = allChapterIndices
.map((index: number) => data[index])
.map((chapter: any) => this.formatChapter(chapter, data))
.filter((chapter): chapter is Plugin.ChapterItem => chapter !== null);
chapters.push(...formattedChapters);
}
}
return chapters;
}
// Helper function to format an individual chapter
formatChapter(chapter: any, data: any): Plugin.ChapterItem | null {
const { id, chapter_title, chapter_number, required_tier, date_created } =
chapter;
// Destructure from data using computed property names based on chapter keys
const {
[id]: chapterId,
[chapter_title]: chapterTitle,
[chapter_number]: chapterNumberVal,
[required_tier]: requiredTierVal,
[date_created]: dateCreated,
} = data;
// Validate required fields
if (
chapterId &&
chapterTitle &&
chapterNumberVal >= 0 &&
requiredTierVal !== null &&
dateCreated
) {
const chapterNumber = parseInt(String(chapterNumberVal), 10) || 0;
const requiredTier = parseInt(String(requiredTierVal), 10) || 0;
// Only process chapters that do not require a premium tier
if (requiredTier === 0) {
return {
name: `Chapter ${chapterNumber} - ${chapterTitle}`,
path: `/viewer/${chapterId}`,
releaseTime: dateCreated,
chapterNumber: chapterNumber,
};
}
}
return null;
}
// // Helper function to extract and format chapters
// extractChapters(data: any): Plugin.ChapterItem[] {
// for (const key in data) {
// const value = data[key];
//
// // Change string here if the chapters are stored under a different key
// const chapterKey = 'chapters';
// if (typeof value === 'object' && value !== null && chapterKey in value) {
// const chapterData = this.decodeData(data[value[chapterKey]]);
//
// // Object.values will give us an array of arrays (any[][])
// const chapterArrays: any[][] = Object.values(chapterData);
//
// // Flatten and format the chapters
// return chapterArrays.flatMap((chapters: any[]) => {
// return chapters
// .map((chapter: any) => this.formatChapter(chapter))
// .filter(
// (chapter): chapter is Plugin.ChapterItem => chapter !== null,
// );
// });
// }
// }
//
// return [];
// }
//
// // Helper function to format an individual chapter
// formatChapter(chapter: any): Plugin.ChapterItem | null {
// const { id, chapter_title, chapter_number, required_tier, date_created } =
// chapter;
//
// // Ensure required fields are present and valid
// if (
// id &&
// chapter_title &&
// chapter_number >= 0 &&
// required_tier !== null &&
// date_created
// ) {
// const number = parseInt(chapter_number, 10) || 0;
// const requiredTier = parseInt(required_tier, 10) || 0;
//
// // Only process chapters with a 'requiredTier' of 0
// if (requiredTier === 0) {
// return {
// name: `Chapter ${number}: ${chapter_title}`,
// path: `/viewer/${id}`,
// releaseTime: date_created,
// chapterNumber: number,
// };
// }
// }
//
// return null;
// }
//
// decodeData(code: any) {
// const offset = this.getOffsetIndex(code);
// const params = this.getDecodeParams(code);
// const constant = this.getConstant(code);
// const data = this.getStringsArrayRaw(code);
//
// const getDataAt = (x: number) => data[x - offset];
//
// //reshuffle data array
// // eslint-disable-next-line no-constant-condition
// while (true) {
// try {
// const some_number = this.applyDecodeParams(params, getDataAt);
// if (some_number === constant) break;
// else data.push(data.shift());
// } catch (err) {
// data.push(data.shift());
// }
// }
//
// return this.getChapterData(code, getDataAt);
// }
//
// getOffsetIndex(code: string) {
// // @ts-ignore
// const string = /{(\w+)=\1-0x(?<offset>[0-9a-f]+);/.exec(code).groups.offset;
// return parseInt(string, 16);
// }
//
// /**
// * @returns {string[]}
// */
// getStringsArrayRaw(code: string) {
// // @ts-ignore
// let json = /function \w+\(\){var \w+=(?<array>\['.+']);/.exec(code).groups
// .array;
//
// //replace string single quotes with double quotes and add escaped chars
// json = json.replace(/'(.+?)'([,\]])/g, (match, p1, p2) => {
// return `"${p1.replace(/\\x([0-9a-z]{2})/g, (match: any, p1: string) => {
// //hexadecimal unicode escape chars
// return String.fromCharCode(parseInt(p1, 16));
// })}"${p2}`;
// });
//
// return JSON.parse(json);
// }
//
// /**
// * @returns {{offset: number, divider: number, negated: boolean}[][]}
// */
// getDecodeParams(code: string) {
// // @ts-ignore
// const jsDecodeInt = /while\(!!\[]\){try{var \w+=(?<code>.+?);/.exec(code)
// .groups.code;
// const decodeSections = jsDecodeInt.split('+');
// const params = [];
// for (const section of decodeSections) {
// params.push(this.decodeParamSection(section));
// }
// return params;
// }
//
// /**
// * @param {string} section
// * @returns {{offset: number, divider: number, negated: boolean}[]}
// */
// decodeParamSection(section: string) {
// const sections = section.split('*');
// const params = [];
// for (const section of sections) {
// // @ts-ignore
// const offsetStr = /parseInt\(\w+\(0x(?<offset>[0-9a-f]+)\)\)/.exec(
// section,
// ).groups.offset;
// const offset = parseInt(offsetStr, 16);
// // @ts-ignore
// const dividerStr = /\/0x(?<divider>[0-9a-f]+)/.exec(section).groups
// .divider;
// const divider = parseInt(dividerStr, 16);
// const negated = section.includes('-');
// params.push({ offset, divider, negated });
// }
// return params;
// }
//
// getConstant(code: string) {
// // @ts-ignore
// const constantStr = /}}}\(\w+,0x(?<constant>[0-9a-f]+)\),/.exec(code).groups
// .constant;
// return parseInt(constantStr, 16);
// }
//
// getChapterData(
// code: string,
// getDataAt: { (x: number): any; (arg0: number): any },
// ) {
// let chapterDataStr =
// // @ts-ignore
// /\),\(function\(\){var \w+=\w+;return(?<data>{.+?});/.exec(code).groups
// .data;
//
// //replace hex with decimal
// chapterDataStr = chapterDataStr.replace(/:0x([0-9a-f]+)/g, (match, p1) => {
// const hex = parseInt(p1, 16);
// return `: ${hex}`;
// });
//
// //replace ![] with false and !![] with true
// chapterDataStr = chapterDataStr
// .replace(/:!!\[]/g, ':true')
// .replace(/:!\[]/g, ':false');
//
// //replace string single quotes with double quotes and add escaped chars
// chapterDataStr = chapterDataStr.replace(
// /'(.+?)'([,\]}:])/g,
// (match, p1, p2) => {
// return `"${p1.replace(/\\x([0-9a-z]{2})/g, (match: any, p1: string) => {
// //hexadecimal unicode escape chars
// return String.fromCharCode(parseInt(p1, 16));
// })}"${p2}`;
// },
// );
//
// //parse the data getting methods
// chapterDataStr = chapterDataStr.replace(
// // @ts-ignore
// /:\w+\(0x(?<offset>[0-9a-f]+)\)/g,
// (match, p1) => {
// const offset = parseInt(p1, 16);
// return `:${JSON.stringify(getDataAt(offset))}`;
// },
// );
//
// return JSON.parse(chapterDataStr);
// }
//
// /**
// * @param {{offset: number, divider: number, negated: boolean}[][]} params
// * @param {function(number): string} getDataAt
// */
// applyDecodeParams(
// params: { offset: number; divider: number; negated: boolean }[][],
// getDataAt: { (x: number): any; (arg0: any): string },
// ) {
// let res = 0;
// for (const paramAdd of params) {
// let resInner = 1;
// for (const paramMul of paramAdd) {
// resInner *= parseInt(getDataAt(paramMul.offset)) / paramMul.divider;
// if (paramMul.negated) resInner *= -1;
// }
// res += resInner;
// }
// return res;
// }
async parseChapter(chapterPath: string): Promise<string> {
const url = `${this.site}${chapterPath}/__data.json?x-sveltekit-invalidated=001`;
// Fetch the novel's data in JSON format
const json = await fetchApi(url).then(r => r.json());
const nodes = json.nodes;
// Extract the main novel data from the nodes
const data = this.extractData(nodes);
// Look for chapter container with required fields
const contentKey = 'content';
const notesKey = 'notes';
const footnotesKey = 'footnotes';
// Iterate over each property in data to find chapter containers
for (const key in data) {
const mapping = data[key];
// Check container for keys that match the required fields
if (
mapping &&
typeof mapping === 'object' &&
contentKey in mapping &&
notesKey in mapping &&
footnotesKey in mapping
) {
// Retrieve the chapter's content, notes, and footnotes using the mapping.
const content = data[mapping[contentKey]];
const notes = data[mapping[notesKey]];
const footnotes = data[mapping[footnotesKey]];
// Return the combined parts with appropriate formatting
return (
content +
(notes ? `<h2>Notes</h2><br>${notes}` : '') +
(footnotes ?? '')
);
}
}
// If no mapping object was found, return an empty string or handle appropriately.
return '';
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.SourceNovel[]> {
if (pageNo !== 1) return [];
const url = `${this.site}/api/novels/search?serialization=All&sort=Popular&title=${encodeURIComponent(searchTerm)}`;
const json = await fetchApi(url).then(r => r.json());
return this.parseNovels(json);
}
filters = {
sort: {
label: 'Sort Results By',
value: 'Popular',
options: [
{ label: 'Popular', value: 'Popular' },
{ label: 'Recent', value: 'Recent' },
{ label: 'Views', value: 'Views' },
],
type: FilterTypes.Picker,
},
storyStatus: {
label: 'Status',
value: 'All',
options: [
{ label: 'All', value: 'All' },
{ label: 'Ongoing', value: 'Ongoing' },
{ label: 'Completed', value: 'Completed' },
],
type: FilterTypes.Picker,
},
genres: {
label: 'Genres',
value: [],
options: [
{ label: 'Action', value: 'Action' },
{ label: 'Comedy', value: 'Comedy' },
{ label: 'Drama', value: 'Drama' },
{ label: 'Fantasy', value: 'Fantasy' },
{ label: 'Harem', value: 'Harem' },
{ label: 'Martial Arts', value: 'Martial Arts' },
{ label: 'Modern', value: 'Modern' },
{ label: 'Mystery', value: 'Mystery' },
{ label: 'Psychological', value: 'Psychological' },
{ label: 'Romance', value: 'Romance' },
{ label: 'Slice of life', value: 'Slice of Life' },
{ label: 'Tragedy', value: 'Tragedy' },
],
type: FilterTypes.CheckboxGroup,
},
} satisfies Filters;
}
export default new Genesis();
+162
View File
@@ -0,0 +1,162 @@
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { Filters, FilterTypes } from '@libs/filterInputs';
import { load as loadCheerio } from 'cheerio';
import { NovelStatus } from '@libs/novelStatus';
class InkittPlugin implements Plugin.PluginBase {
id = 'inkitt';
name = 'Inkitt';
icon = 'src/en/inkitt/icon.png';
site = 'https://www.inkitt.com';
version = '1.0.1';
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
//flag indicates whether access to LocalStorage, SesesionStorage is required.
webStorageUtilized?: boolean;
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
let req;
if (filters?.genres?.value) {
req = await fetchApi(
this.site +
`/genre/${filters.genres.value}/${pageNo}?period=alltime&sort=popular`,
);
} else {
req = await fetchApi(
this.site + `/trending_stories?page=${pageNo}&period=alltime`,
);
}
let data;
try {
data = await req.json();
} catch (e) {
throw new Error('Failed to load novels, try opening in webview.');
}
return data.stories.map((novel: any) => {
return {
name: novel.title,
path: this.getPath(novel),
cover:
novel.vertical_cover.url ||
novel.vertical_cover.iphone ||
novel.cover.url,
};
});
}
getPath(novel: any) {
return (novel.category_one || novel.genres[0]) + '/' + novel.id;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const req = await fetchApi(this.site + `/stories/${novelPath}`);
const text = await req.text();
const loadedCheerio = loadCheerio(text);
const novel: Plugin.SourceNovel = {
path: novelPath,
name: loadedCheerio('h1.story-title').text(),
};
novel.author = loadedCheerio('dl > dd > a.author-link').text();
novel.genres = loadedCheerio('dd.genres > a')
.map((i, el) => loadedCheerio(el).text())
.toArray()
.join(', ');
const status = loadedCheerio('div.dlc > dl:has(dt:contains("Status")) > dd')
.text()
.trim();
if (status === 'Complete') novel.status = NovelStatus.Completed;
if (status === 'Ongoing') novel.status = NovelStatus.Ongoing;
const apiReq = await fetchApi(
this.site + `/api/stories/${novelPath.split('/')[1]}`,
);
const apiData = await apiReq.json();
novel.cover = apiData.vertical_cover.url;
novel.summary = loadedCheerio('p.story-summary').text();
novel.chapters = apiData.chapters.map((c: any) => {
return {
name: c.name,
path: novelPath + '/chapters/' + c.chapter_number,
chapterNumber: c.chapter_number,
};
});
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const req = await fetchApi(this.site + '/stories/' + chapterPath);
const text = await req.text();
const loadedCheerio = loadCheerio(text);
return loadedCheerio('div#chapterText').html() || '';
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
const req = await fetchApi(
this.site +
`/api/2/search/title?q=${encodeURIComponent(searchTerm)}&page=${pageNo}`,
);
let data;
try {
data = await req.json();
} catch (e) {
throw new Error('Failed to search novels, try opening in webview.');
}
return data.stories.map((novel: any) => {
return {
name: novel.title,
path: this.getPath(novel),
cover:
novel.vertical_cover.url ||
novel.vertical_cover.iphone ||
novel.cover.url,
};
});
}
resolveUrl = (path: string, isNovel?: boolean) =>
this.site + '/stories/' + path;
filters = {
genres: {
type: FilterTypes.Picker,
label: 'Genre',
value: '',
options: [
{ 'label': 'Sci-Fi', 'value': 'scifi' },
{ 'label': 'Fantasy', 'value': 'fantasy' },
{ 'label': 'Adventure', 'value': 'adventure' },
{ 'label': 'Mystery', 'value': 'mystery' },
{ 'label': 'Action', 'value': 'action' },
{ 'label': 'Horror', 'value': 'horror' },
{ 'label': 'Humor', 'value': 'humor' },
{ 'label': 'Erotica', 'value': 'erotica' },
{ 'label': 'Poetry', 'value': 'poetry' },
{ 'label': 'Other', 'value': 'other' },
{ 'label': 'Thriller', 'value': 'thriller' },
{ 'label': 'Romance', 'value': 'romance' },
{ 'label': 'Children', 'value': 'children' },
{ 'label': 'Drama', 'value': 'drama' },
],
},
} satisfies Filters;
}
export default new InkittPlugin();
+377
View File
@@ -0,0 +1,377 @@
import { load as parseHTML } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { defaultCover } from '@libs/defaultCover';
import { NovelStatus } from '@libs/novelStatus';
class KDTNovels implements Plugin.PluginBase {
id = 'kdtnovels';
name = 'KDT Novels';
version = '1.0.0';
icon = 'src/en/kdtnovels/icon.png';
site = 'https://kdtnovels.com/';
/**
* Parse novel items from HTML using common selectors
*/
private parseNovelItems(html: string): Plugin.NovelItem[] {
const $ = parseHTML(html);
const novels: Plugin.NovelItem[] = [];
$('div.c-tabs-item__content').each((_, element) => {
const $element = $(element);
// Extract cover image
const coverImg = $element.find('div.tab-thumb img').first();
const cover =
coverImg.attr('data-src') || coverImg.attr('src') || defaultCover;
// Extract title and URL
const titleLink = $element.find('div.post-title > h3 > a').first();
const name = titleLink.text().trim();
const href = titleLink.attr('href');
// Only add if we have required data
if (name && href) {
// Convert full URL to relative path, remove leading and trailing slashes
const path = href.replace(this.site, '').replace(/^\/+|\/+$/g, '');
novels.push({
name,
path,
cover: cover || defaultCover,
});
}
});
return novels;
}
/**
* Parse status text and map it to NovelStatus enum
*/
private parseNovelStatus(statusText: string): string {
if (!statusText) {
return NovelStatus.Unknown;
}
const normalizedStatus = statusText.toLowerCase().trim();
// Map common status patterns to NovelStatus enum values
if (
normalizedStatus.includes('ongoing') ||
normalizedStatus.includes('on going')
) {
return NovelStatus.Ongoing;
}
if (
normalizedStatus.includes('completed') ||
normalizedStatus.includes('complete')
) {
return NovelStatus.Completed;
}
if (normalizedStatus.includes('licensed')) {
return NovelStatus.Licensed;
}
if (
normalizedStatus.includes('finished') ||
normalizedStatus.includes('publishing finished')
) {
return NovelStatus.PublishingFinished;
}
if (
normalizedStatus.includes('cancelled') ||
normalizedStatus.includes('canceled')
) {
return NovelStatus.Cancelled;
}
if (
normalizedStatus.includes('hiatus') ||
normalizedStatus.includes('on hiatus')
) {
return NovelStatus.OnHiatus;
}
// If no pattern matches, return the original text or Unknown
return statusText || NovelStatus.Unknown;
}
async popularNovels(
pageNo: number,
options: Plugin.PopularNovelsOptions,
): Promise<Plugin.NovelItem[]> {
// Construct URL based on whether we want latest or popular novels
const orderBy = options.showLatestNovels ? 'latest' : 'views';
const url = `${this.site}/page/${pageNo}/?s&post_type=wp-manga&m_orderby=${orderBy}`;
try {
// Fetch the search results page
const response = await fetchApi(url);
const html = await response.text();
// Use common parsing method
return this.parseNovelItems(html);
} catch (error) {
console.error('Error fetching popular novels:', error);
return [];
}
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
// Construct search URL with proper query parameters
const url = `${this.site}/page/${pageNo}/?s=${encodeURIComponent(searchTerm)}&post_type=wp-manga&op&author&artist&release&adult`;
try {
// Fetch the search results page
const response = await fetchApi(url);
const html = await response.text();
// Use common parsing method
const novels = this.parseNovelItems(html);
// Handle empty search results gracefully
if (novels.length === 0) {
console.log(
`No search results found for term: "${searchTerm}" on page ${pageNo}`,
);
}
return novels;
} catch (error) {
console.error('Error searching novels:', error);
return [];
}
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const novelUrl = this.site + novelPath;
try {
// Fetch the novel details page
const response = await fetchApi(novelUrl);
const html = await response.text();
// Parse HTML with Cheerio
const $ = parseHTML(html);
const novel: Plugin.SourceNovel = {
name: '',
path: novelPath,
cover: defaultCover,
author: '',
artist: '',
genres: '',
summary: '',
status: '',
chapters: [],
};
// Extract novel metadata using provided CSS selectors
// Extract novel title
const titleElement = $('.manga-title').first();
novel.name = titleElement.text().trim() || 'Unknown Title';
// Extract cover image
const coverElement = $('div.summary_image img').first();
const coverSrc =
coverElement.attr('data-src') || coverElement.attr('src');
novel.cover = coverSrc || defaultCover;
// Extract genres
const genreElements = $('div.genres-content a');
const genres: string[] = [];
genreElements.each((_, element) => {
const genre = $(element).text().trim();
if (genre) {
genres.push(genre);
}
});
novel.genres = genres.join(',');
// Extract summary/synopsis
const summaryElements = $('div.manga-excerpt p');
const summaryParts: string[] = [];
summaryElements.each((_, element) => {
const text = $(element).text().trim();
if (text) {
summaryParts.push(text);
}
});
novel.summary = summaryParts.join('\n\n');
// Extract status
const statusElement = $('div.manga-status span:nth-child(2)').first();
const rawStatus = statusElement.text().trim();
novel.status = this.parseNovelStatus(rawStatus);
// Extract chapter list - chapters are loaded dynamically via AJAX
let chapters: Plugin.ChapterItem[] = [];
try {
// Construct AJAX URL for chapter list
const ajaxUrl = `${novelUrl}/ajax/chapters/?t=1`;
// Make POST request to get chapter list HTML fragment
const chapterResponse = await fetchApi(ajaxUrl, {
method: 'POST',
});
const chapterHtml = await chapterResponse.text();
// Parse the HTML fragment
const $chapters = parseHTML(chapterHtml);
// Extract chapters using existing parsing logic
const chapterElements = $chapters('li.free-chap');
chapterElements.each((index, element) => {
const $element = $chapters(element);
// Extract chapter link and name
const chapterLink = $element.find('a').first();
const chapterName = chapterLink.text().trim();
const chapterHref = chapterLink.attr('href');
// Extract release date
const releaseDateElement = $element
.find('span.chapter-release-date')
.first();
const releaseTime = releaseDateElement.text().trim() || null;
// Extract chapter number from title (opportunistic parsing)
let chapterNumber = chapterElements.length - index; // Default fallback (reverse order)
// Try multiple patterns for chapter number extraction (supporting decimals)
let chapterNumberMatch = chapterName.match(/Ch\s*(\d+(?:\.\d+)?)/i);
if (!chapterNumberMatch) {
chapterNumberMatch = chapterName.match(/c(\d+(?:\.\d+)?)/i);
}
if (chapterNumberMatch) {
chapterNumber = parseFloat(chapterNumberMatch[1]);
}
if (chapterName && chapterHref) {
// Extract just the pathname from the URL, removing leading and trailing slashes
let chapterPath: string;
if (chapterHref.startsWith('http')) {
const url = new URL(chapterHref);
chapterPath = url.pathname.replace(/^\/+|\/+$/g, '');
} else {
chapterPath = chapterHref.replace(/^\/+|\/+$/g, '');
}
chapters.push({
name: chapterName,
path: chapterPath,
releaseTime: releaseTime,
chapterNumber: chapterNumber,
});
}
});
// Reverse the chapters array since they come in reverse order (latest first)
chapters.reverse();
} catch (chapterError) {
console.error('Error fetching chapter list:', chapterError);
// Fall back to empty chapters array if AJAX request fails
chapters = [];
}
novel.chapters = chapters;
return novel;
} catch (error) {
console.error('Error parsing novel:', error);
// Return basic novel object with minimal data on error
return {
name: 'Error loading novel',
path: novelPath,
cover: defaultCover,
chapters: [],
};
}
}
async parseChapter(chapterPath: string): Promise<string> {
const chapterUrl = this.site + chapterPath;
try {
// Implement HTTP request to fetch chapter page
const response = await fetchApi(chapterUrl);
const html = await response.text();
// Parse HTML with Cheerio
const $ = parseHTML(html);
// Identify main chapter content container using CSS selectors
const contentContainer = $('div.reading-content').first();
if (contentContainer.length === 0) {
console.warn('Chapter content container not found');
return '';
}
// Remove hidden input elements used for tracking (infinite scrolling)
contentContainer.find('input[type="hidden"]').remove();
// Handle embedded images - ensure they have proper attributes
contentContainer.find('img').each((_, img) => {
const $img = $(img);
// Handle lazy-loaded images by moving data-src to src if needed
const dataSrc = $img.attr('data-src');
if (dataSrc && !$img.attr('src')) {
$img.attr('src', dataSrc);
}
// Ensure images have alt text for accessibility
if (!$img.attr('alt')) {
$img.attr('alt', 'Chapter image');
}
});
// Handle special formatting elements - preserve paragraph structure
// Ensure proper spacing between paragraphs and other block elements
contentContainer.find('p, div, br').each((_, element) => {
const $element = $(element);
// Ensure paragraphs have proper spacing
if (element.tagName?.toLowerCase() === 'p' && $element.text().trim()) {
// Paragraph already has proper HTML structure
}
// Handle div elements that might contain text
if (
element.tagName?.toLowerCase() === 'div' &&
$element.text().trim()
) {
// Preserve div structure for special formatting
}
});
// Extract the HTML content while preserving formatting
let chapterContent = contentContainer.html();
// Filter out HTML comments
if (chapterContent) {
chapterContent = chapterContent.replace(/<!--[\s\S]*?-->/g, '');
}
// Return properly formatted HTML string
return chapterContent?.trim() || '';
} catch (error) {
console.error('Error parsing chapter:', error);
return '';
}
}
}
export default new KDTNovels();
+499
View File
@@ -0,0 +1,499 @@
import { Parser } from 'htmlparser2';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { Filters, FilterTypes } from '@libs/filterInputs';
import dayjs from 'dayjs';
class LightNovelPub implements Plugin.PagePlugin {
id = 'lightnovelpub';
name = 'LightNovelPub';
version = '2.2.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: Partial<Plugin.NovelItem> = {};
let state: ParsingState = ParsingState.Idle;
const parser = new Parser({
onopentag(name, attribs) {
if (attribs['class'] === 'novel-item') {
state = ParsingState.Novel;
}
if (state !== ParsingState.Novel) return;
switch (name) {
case 'a':
tempNovel.path = attribs['href'].slice(1);
tempNovel.name = attribs['title'];
break;
case 'img':
tempNovel.cover = attribs['data-src'] || attribs['src'];
break;
}
},
onclosetag(name) {
if (name === 'li') {
if (tempNovel.path && tempNovel.cover) {
novels.push(tempNovel as Plugin.NovelItem);
tempNovel = {};
}
state = ParsingState.Idle;
}
},
});
parser.write(html);
parser.end();
return novels;
}
async popularNovels(
page: number,
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
const linkParts = [
this.site + 'browse',
filters.genres.value,
filters.order.value,
filters.status.value,
page.toString(),
];
const body = await fetchApi(linkParts.join('/')).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: Partial<Plugin.SourceNovel> & Partial<{ totalPages: number }> =
{
path: novelPath,
chapters: [],
};
let state: ParsingState = ParsingState.Idle;
const summaryParts: string[] = [];
const genreArray: string[] = [];
const parser = new Parser({
onopentag(name, attribs) {
switch (name) {
case 'h1':
if (attribs['class']?.includes('novel-title')) {
state = ParsingState.NovelName;
}
break;
case 'figure':
if (attribs['class'] === 'cover') {
state = ParsingState.Cover;
}
break;
case 'img':
if (state === ParsingState.Cover) {
novel.cover = attribs['data-src'] || attribs['src'];
}
break;
case 'strong':
if (state === ParsingState.HeaderStats) {
if (attribs['class']) {
state = ParsingState.Status;
} else {
state = ParsingState.TotalChapters;
}
}
break;
case 'br':
if (state === ParsingState.Summary) {
summaryParts.push('<LINE_BREAK>');
}
break;
case 'a':
if (state === ParsingState.Genres) {
state = ParsingState.Tags;
}
break;
case 'div':
if (attribs['class']) {
if (attribs['class'].includes('content')) {
state = ParsingState.Summary;
} else {
const map: Record<string, ParsingState> = {
'categories': ParsingState.Genres,
'header-stats': ParsingState.HeaderStats,
'expand': ParsingState.Idle,
};
state = map[attribs['class']] ?? state;
}
}
break;
default:
if (attribs['itemprop'] === 'author') {
state = ParsingState.AuthorName;
}
break;
}
},
ontext(data) {
switch (state) {
case ParsingState.TotalChapters:
if (!novel.totalPages) {
novel.totalPages = Math.ceil(parseInt(data, 10) / 100);
}
break;
case ParsingState.Status:
novel.status = data.trim();
break;
case ParsingState.NovelName:
novel.name = (novel.name || '') + data.trim();
break;
case ParsingState.AuthorName:
novel.author = data;
break;
case ParsingState.Summary:
summaryParts.push(data);
break;
case ParsingState.Tags:
genreArray.push(data);
break;
}
},
onclosetag(name) {
switch (name) {
case 'strong':
if (state === ParsingState.TotalChapters) {
state = ParsingState.HeaderStats;
} else if (state === ParsingState.Status) {
state = ParsingState.Idle;
}
break;
case 'i':
if (state === ParsingState.Status) {
state = ParsingState.Idle;
}
break;
case 'h1':
if (state === ParsingState.NovelName) {
state = ParsingState.Idle;
}
break;
case 'span':
if (state === ParsingState.AuthorName) {
state = ParsingState.Idle;
}
break;
case 'div':
if (
state === ParsingState.Summary ||
state === ParsingState.Genres
) {
state = ParsingState.Idle;
}
break;
case 'a':
if (state === ParsingState.Tags) {
state = ParsingState.Genres;
}
break;
case 'figure':
if (state === ParsingState.Cover) {
state = ParsingState.Idle;
}
break;
case 'p':
if (state === ParsingState.Summary) {
summaryParts.push('<PARAGRAPH_BREAK>');
}
break;
}
},
onend() {
const text = summaryParts
.join('')
.replace(/<PARAGRAPH_BREAK>/g, '\n\n')
.replace(/<LINE_BREAK>/g, '\n')
.replace(/\r\n/g, '\n')
.replace(/&nbsp;/g, ' ');
const paragraphs = text
.split('\n\n')
.map(p => p.trim().replace(/[ \t]+/g, ' '))
.filter(p => p.length > 0);
novel.summary = paragraphs.join('\n\n');
summaryParts.length = 0;
novel.genres = genreArray.join(', ');
},
});
parser.write(body);
parser.end();
return novel as Plugin.SourceNovel & { totalPages: number };
}
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 chapters: Plugin.ChapterItem[] = [];
let tempChapter: Partial<Plugin.ChapterItem> = {};
let state: ParsingState = ParsingState.Idle;
const parser = new Parser({
onopentag(name, attribs) {
if (attribs['class'] === 'chapter-list') {
state = ParsingState.ChapterList;
return;
}
switch (state) {
case ParsingState.ChapterList:
if (name === 'li') {
state = ParsingState.ChapterItem;
tempChapter = {
chapterNumber: Number(attribs['data-orderno'] || 0),
};
}
break;
case ParsingState.ChapterItem:
switch (name) {
case 'a':
tempChapter.name = attribs['title'];
tempChapter.path = attribs['href']?.slice(1);
break;
case 'time':
tempChapter.releaseTime = dayjs(
attribs['datetime'],
).toISOString();
break;
}
break;
}
},
onclosetag(name) {
switch (state) {
case ParsingState.ChapterItem:
if (name === 'li') {
if (
tempChapter.chapterNumber !== undefined &&
tempChapter.path &&
tempChapter.releaseTime
) {
chapters.push(tempChapter as Plugin.ChapterItem);
}
state = ParsingState.ChapterList;
}
break;
case ParsingState.ChapterList:
if (name === 'ul') {
state = ParsingState.Idle;
}
break;
}
},
});
parser.write(body);
parser.end();
return { chapters };
}
async parseChapter(chapterPath: string): Promise<string> {
const html = await fetchApi(this.site + chapterPath).then(r => r.text());
let depth = 0;
let state: ParsingState = ParsingState.Idle;
const chapterHtml: string[] = [];
type EscapeChar = '&' | '<' | '>' | '"' | "'";
const escapeRegex = /[&<>"']/g;
const escapeMap: Record<EscapeChar, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
const escapeHtml = (text: string): string =>
text.replace(escapeRegex, char => escapeMap[char as EscapeChar]);
const parser = new Parser({
onopentag(name, attribs) {
switch (state) {
case ParsingState.Idle:
if (name === 'div' && attribs['id'] === 'chapter-container') {
state = ParsingState.Chapter;
depth++;
}
break;
case ParsingState.Chapter:
if (name === 'div') depth++;
break;
default:
return;
}
if (state === ParsingState.Chapter) {
const attr = Object.keys(attribs).map(key => {
const value = attribs[key].replace(/"/g, '&quot;');
return ` ${key}="${value}"`;
});
chapterHtml.push(`<${name}${attr.join('')}>`);
}
},
ontext(text) {
if (state === ParsingState.Chapter) {
chapterHtml.push(escapeHtml(text));
}
},
onclosetag(name) {
if (state === ParsingState.Chapter) {
if (!parser['isVoidElement'](name)) {
chapterHtml.push(`</${name}>`);
}
if (name === 'div') depth--;
if (depth === 0) {
state = ParsingState.Stopped;
}
}
},
});
parser.write(html);
parser.end();
return chapterHtml.join('');
}
async searchNovels(searchTerm: string): 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();
enum ParsingState {
Idle,
Novel,
HeaderStats,
Status,
Stopped,
Chapter,
ChapterItem,
ChapterList,
TotalChapters,
NovelName,
AuthorName,
Summary,
Genres,
Tags,
Cover,
}
+208
View File
@@ -0,0 +1,208 @@
import { fetchApi, fetchProto, fetchText } from '@/lib/fetch';
import { Plugin } from '@/types/plugin';
import { Filters } from '@libs/filterInputs';
import { load as loadCheerio } from 'cheerio';
import { defaultCover } from '@libs/defaultCover';
import { NovelStatus } from '@libs/novelStatus';
// import { isUrlAbsolute } from '@libs/isAbsoluteUrl';
// import { storage, localStorage, sessionStorage } from '@/lib/storage';
// import { encode, decode } from 'urlencode';
// import dayjs from 'dayjs';
import { Parser } from 'htmlparser2';
class LNTPlugin implements Plugin.PluginBase {
id = 'lightnoveltranslations';
name = 'Light Novel Translations';
icon = 'src/en/lightnoveltranslations/icon.png';
site = 'https://lightnovelstranslations.com/';
version = '1.0.0';
filters: Filters | undefined = undefined;
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
//flag indicates whether access to LocalStorage, SesesionStorage is required.
webStorageUtilized?: boolean;
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
let link = this.site + 'read/';
link += `page/${pageNo}`;
link += `?sortby=${showLatestNovels ? 'most-recent' : 'most-liked'}`;
const body = await fetchApi(link);
const html = await body.text();
const loadedCheerio = loadCheerio(html);
const baseUrl = this.site;
const novels: Plugin.NovelItem[] = [];
loadedCheerio('div.read_list-story-item').each((i, el) => {
const tempNovel = {} as Plugin.NovelItem;
const img = loadedCheerio(el)
.find('.item_thumb')
.find('img')
.first()
.attr('src');
let path = loadedCheerio(el)
.find('.item_thumb')
.find('a')
.first()
.attr('href');
path = path ? path.slice(baseUrl.length) : '';
const title = loadedCheerio(el)
.find('.item_thumb')
.find('a')
.first()
.attr('title');
tempNovel.name = title ? title : '';
tempNovel.path = path;
tempNovel.cover = img;
novels.push(tempNovel);
});
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const url = this.site + novelPath;
const body = await fetchApi(url);
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: [],
};
const loadedCheerio = loadCheerio(html);
novel.cover = loadedCheerio('div.novel-image').find('img').attr('src');
novel.status = loadedCheerio('div.novel_status').text().trim();
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;
}
novel.name = loadedCheerio('div.novel_title')
.find('h3')
.first()
.text()
.trim();
novel.author = loadedCheerio('div.novel_detail_info')
.find('li')
.filter(function () {
return loadedCheerio(this).text().includes('Author');
})
.first()
.text()
.trim();
const body2 = await fetchApi(url.replace('?tab=table_contents', ''));
const html2 = await body2.text().then(r => r.replace(/>\s+</g, '><'));
const loadedCheerio2 = loadCheerio(html2);
novel.summary = loadedCheerio2('div.novel_text')
.find('p')
.first()
.text()
.trim();
const baseUrl = this.site;
const chapters: Plugin.ChapterItem[] = [];
loadedCheerio('li.chapter-item.unlock').each((i, el) => {
const chapterName = loadedCheerio(el).find('a').text().trim();
const chapterPath = loadedCheerio(el).find('a').attr('href');
if (chapterPath) {
const chapter: Plugin.ChapterItem = {
name: chapterName,
path: chapterPath.slice(baseUrl.length),
};
chapters.push(chapter);
}
});
novel.chapters = chapters;
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
// parse chapter text here
const body = await fetchApi(this.site + chapterPath);
const html = await body.text().then(r => r.replace(/>\s+</g, '><'));
const loadedCheerio = loadCheerio(html);
const chapterText = loadedCheerio('div.text_story');
chapterText.find('div.ads_content').remove();
return chapterText.html() || '';
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
// get novels using the search term
if (pageNo !== 1) return [];
const searchUrl = this.site + '/read';
const formData = new FormData();
formData.append('field-search', searchTerm);
const results = await fetchApi(searchUrl, {
method: 'POST',
body: formData,
});
const body = await results.text();
const loadedCheerio = loadCheerio(body);
const novels: Plugin.NovelItem[] = [];
loadedCheerio('div.read_list-story-item').each((i, el) => {
const tempNovel = {} as Plugin.NovelItem;
const img = loadedCheerio(el)
.find('.item_thumb')
.find('img')
.first()
.attr('src');
let path = loadedCheerio(el)
.find('.item_thumb')
.find('a')
.first()
.attr('href');
path = path ? path.slice(this.site.length) : '';
const title = loadedCheerio(el)
.find('.item_thumb')
.find('a')
.first()
.attr('title');
tempNovel.name = title ? title : '';
tempNovel.path = path;
tempNovel.cover = img;
novels.push(tempNovel);
});
type SearchEntry = {
title: string;
thumbnail: string;
permalink: string;
};
return novels;
}
resolveUrl = (path: string, isNovel?: boolean) =>
this.site + (isNovel ? '/book/' : '/chapter/') + path;
}
export default new LNTPlugin();
+435
View File
@@ -0,0 +1,435 @@
import { Parser } from 'htmlparser2';
import { fetchApi } from '@libs/fetch';
import { FilterTypes, Filters } from '@libs/filterInputs';
import { Plugin } from '@/types/plugin';
class LnMTLPlugin implements Plugin.PagePlugin {
id = 'lnmtl';
name = 'LnMTL';
icon = 'src/en/lnmtl/icon.png';
site = 'https://lnmtl.com/';
version = '2.1.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[]> {
const params = new URLSearchParams({
orderBy: filters.order.value,
order: filters.sort.value,
filter: filters.storyStatus.value,
page: page.toString(),
});
const link = `${this.site}novel?${params.toString()}`;
const response = await fetchApi(link);
const html = await response.text();
const baseUrl = this.site;
let state: ParsingState = ParsingState.Idle;
let tempNovel: Partial<Plugin.NovelItem> = {};
const novels: Plugin.NovelItem[] = [];
const parser = new Parser({
onopentag(name, attribs) {
if (attribs['class']?.includes('media-left')) {
state = ParsingState.Novel;
}
if (state !== ParsingState.Novel) return;
switch (name) {
case 'a':
tempNovel.path = attribs['href'].replace(baseUrl, '');
break;
case 'img':
tempNovel.name = attribs['alt'];
tempNovel.cover = attribs['src'];
break;
}
},
onclosetag(name) {
if (name === 'div') {
if (tempNovel.path && tempNovel.name) {
novels.push(tempNovel as Plugin.NovelItem);
tempNovel = {};
}
state = ParsingState.Idle;
}
},
});
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: Partial<Plugin.SourceNovel> & { totalPages: number } = {
path: novelPath,
totalPages: 1,
chapters: [],
};
let state = ParsingState.Idle;
let panelValueCount = 0;
let listCount = 0;
let isAuthorKey = false;
let isStatusKey = false;
const summaryParts: string[] = [];
const genreArray: string[] = [];
const parser = new Parser({
onopentag(name, attribs) {
switch (name) {
case 'img':
if (attribs['class']?.includes('img-rounded')) {
novel.name = attribs['title'];
novel.cover = attribs['src'];
}
break;
case 'dt':
state = ParsingState.InPanelKey;
break;
case 'dd':
state = ParsingState.InPanelValue;
panelValueCount++;
break;
case 'ul':
if (attribs['class']?.includes('list-inline')) {
listCount++;
}
break;
case 'li':
if (listCount === 1) {
state = ParsingState.InGenres;
}
break;
default:
if (attribs['class']) {
const map: Record<string, ParsingState> = {
description: ParsingState.InDescription,
source: ParsingState.InSource,
progress: ParsingState.Idle,
};
state = map[attribs['class']] ?? state;
}
}
},
ontext(data) {
switch (state) {
case ParsingState.InScript:
const volume = JSON.parse(
data.match(/lnmtl.volumes = (.+])(?=;)/)![1] || '',
);
novel.totalPages = volume.length;
break;
case ParsingState.InDescription:
summaryParts.push(data.trim());
summaryParts.push('\n\n');
break;
case ParsingState.InSource:
summaryParts.push(data);
break;
case ParsingState.InPanelKey:
switch (data) {
case 'Authors':
isAuthorKey = true;
break;
case 'Current status':
isStatusKey = true;
break;
}
break;
case ParsingState.InPanelValue:
if (isAuthorKey && panelValueCount === 1) {
novel.author = (novel.author || '') + data.trim();
isAuthorKey = false;
} else if (isStatusKey && panelValueCount === 2) {
novel.status = (novel.status || '') + data.trim();
isStatusKey = false;
}
break;
case ParsingState.InGenres:
genreArray.push(data.trim());
break;
}
},
onclosetag(name) {
switch (name) {
case 'ul':
if (state === ParsingState.InGenres) {
state = ParsingState.Idle;
}
break;
case 'main':
state = ParsingState.InScript;
break;
case 'script':
if (state === ParsingState.InScript) {
state = ParsingState.Idle;
}
break;
}
},
onend() {
novel.summary = summaryParts.join('');
novel.genres = genreArray.join(', ');
},
});
parser.write(html);
parser.end();
return novel as Plugin.SourceNovel & { totalPages: number };
}
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 state: ParsingState = ParsingState.Idle;
let volume: VolumeEntry = {
id: '',
title: '',
};
const parser = new Parser({
ontext(data) {
if (state === ParsingState.InScript) {
const volumes = JSON.parse(
data.match(/lnmtl.volumes = (.+])(?=;)/)![1] || '',
);
volume = volumes[+page - 1];
}
},
onclosetag(name) {
if (name === 'main') {
state = ParsingState.InScript;
}
if (name === 'script') {
state = ParsingState.Idle;
}
},
});
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 state: ParsingState = ParsingState.Idle;
const chapterTextParts: string[] = [];
type EscapeChar = '&' | '<' | '>' | '"' | "'";
const escapeRegex = /[&<>"']/g;
const escapeMap: Record<EscapeChar, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
const escapeHtml = (text: string): string =>
escapeRegex.test(text)
? ((escapeRegex.lastIndex = 0),
text.replace(escapeRegex, char => escapeMap[char as EscapeChar]))
: text;
const parser = new Parser({
onopentag(name, attribs) {
if (name === 'sentence' && attribs['class']?.includes('translated')) {
state = ParsingState.Chapter;
chapterTextParts.push('<p>');
}
},
onopentagname(name) {
if (name === 'nav') {
state = ParsingState.Idle;
}
},
ontext(data) {
if (state === ParsingState.Chapter) {
chapterTextParts.push(escapeHtml(data));
}
},
onclosetag(name) {
if (name === 'sentence' && state === ParsingState.Chapter) {
chapterTextParts.push('</p>');
state = ParsingState.Idle;
}
},
});
parser.write(html);
parser.end();
const chapterText = chapterTextParts.join('');
return chapterText.replace(/„/g, '“');
}
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
const html = await fetchApi(this.site)
.then(b => b.text())
.then(r => r.replace(/>\s+</g, '><'));
let state = ParsingState.Idle;
const listPart = new Set<string>();
const parser = new Parser({
onopentag(name, attribs) {
if (
state === ParsingState.InFooter &&
name === 'script' &&
attribs['type']?.includes('application/javascript')
) {
state = ParsingState.InScript;
}
},
ontext(data) {
if (state === ParsingState.InScript) {
const match = data.match(/prefetch: '\/(.*?\.json)/);
if (match) {
listPart.add(match[1]);
}
}
},
onclosetag(name) {
switch (name) {
case 'footer':
state = ParsingState.InFooter;
break;
case 'script':
if (state === ParsingState.InScript) {
state = ParsingState.Idle;
}
break;
}
},
});
parser.write(html);
parser.end();
const search = await fetchApi(
`${this.site}${Array.from(listPart).join('')}`,
);
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();
type ChapterEntry = {
number: number;
title: string;
slug: string;
created_at: string;
};
type VolumeEntry = {
id: string;
title: string;
};
enum ParsingState {
Idle,
InFooter,
InScript,
InDescription,
InSource,
InPanelKey,
InPanelValue,
InGenres,
Novel,
Chapter,
}
+101
View File
@@ -0,0 +1,101 @@
import { CheerioAPI, load as parseHTML } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
class MTLReader implements Plugin.PluginBase {
id = 'mtlreader';
name = 'MTL Reader';
version = '1.0.1';
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): 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): 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=${encodeURIComponent(searchTerm)}`;
const seacrhBody = await fetchApi(searchUrl).then(r => r.text());
const loadedCheerio = parseHTML(seacrhBody);
return this.parseNovels(loadedCheerio);
}
}
export default new MTLReader();
File diff suppressed because it is too large Load Diff
+294
View File
@@ -0,0 +1,294 @@
import { CheerioAPI, load as parseHTML } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { Filters, FilterTypes } from '@libs/filterInputs';
class NovelBuddy implements Plugin.PluginBase {
id = 'novelbuddy';
name = 'NovelBuddy.io';
site = 'https://novelbuddy.io/';
version = '1.0.3';
icon = 'src/en/novelbuddy/icon.png';
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,
{ filters }: Plugin.PopularNovelsOptions<Filters>,
): Promise<Plugin.NovelItem[]> {
// create empty query params
const params = new URLSearchParams();
// apply all filters
params.append('sort', filters.orderBy.value.toString());
params.append('status', filters.status.value.toString());
if (filters.genre.value instanceof Array) {
filters.genre.value.forEach(genre => {
params.append('genre[]', genre.toString());
});
}
params.append('q', filters.keyword.value.toString());
params.append('page', pageNo.toString());
const url = `${this.site}search?${params.toString()}`;
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 .content').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',
];
const monthsJoined = months.join('|');
const rx = new RegExp(
`(${monthsJoined}) (\\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=${encodeURIComponent(searchTerm)}&page=${page}`;
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = parseHTML(body);
return this.parseNovels(loadedCheerio);
}
filters = {
orderBy: {
value: 'views',
label: 'Order by',
options: [
{ label: 'Views', value: 'views' },
{ label: 'Updated At', value: 'updated_at' },
{ label: 'Created At', value: 'created_at' },
{ label: 'Name', value: 'name' },
{ label: 'Rating', value: 'rating' },
],
type: FilterTypes.Picker,
},
keyword: {
value: '',
label: 'Keywords',
type: FilterTypes.TextInput,
},
status: {
value: 'all',
label: 'Status',
options: [
{ label: 'All', value: 'all' },
{ label: 'Ongoing', value: 'ongoing' },
{ label: 'Completed', value: 'completed' },
],
type: FilterTypes.Picker,
},
genre: {
value: [],
label: 'Genres (OR, not AND)',
options: [
{ label: 'Action', value: 'action' },
{ label: 'Action Adventure', value: 'action-adventure' },
{ label: 'Adult', value: 'adult' },
{ label: 'Adventcure', value: 'adventcure' },
{ label: 'Adventure', value: 'adventure' },
{ label: 'Adventurer', value: 'adventurer' },
{ label: 'Bender', value: 'bender' },
{ label: 'Chinese', value: 'chinese' },
{ label: 'Comedy', value: 'comedy' },
{ label: 'Cultivation', value: 'cultivation' },
{ 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', value: 'gender' },
{ label: 'Gender Bender', value: 'gender-bender' },
{ label: 'Harem', value: 'harem' },
{ label: 'HaremAction', value: 'haremaction' },
{ label: 'Haremv', value: 'haremv' },
{ label: 'Historica', value: 'historica' },
{ label: 'Historical', value: 'historical' },
{ label: 'History', value: 'history' },
{ label: 'Horror', value: 'horror' },
{ label: 'Isekai', value: 'isekai' },
{ label: 'Josei', value: 'josei' },
{ label: 'Lolicon', value: 'lolicon' },
{ label: 'Magic', value: 'magic' },
{ label: 'Martial', value: 'martial' },
{ 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: 'Mystery', value: 'mystery' },
{ label: 'Mystery Adventure', value: 'mystery-adventure' },
{ label: 'Psychologic', value: 'psychologic' },
{ label: 'Psychological', value: 'psychological' },
{ label: 'Reincarnation', value: 'reincarnation' },
{ label: 'Romance', value: 'romance' },
{ label: 'Romance Adventure', value: 'romance-adventure' },
{ label: 'Romance Harem', value: 'romance-harem' },
{ label: 'Romancem', value: 'romancem' },
{ 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: 'Superna', value: 'superna' },
{ label: 'Supernatural', value: 'supernatural' },
{ label: 'System', value: 'system' },
{ label: 'Tragedy', value: 'tragedy' },
{ label: 'Urban', value: 'urban' },
{ label: 'Urban Life', value: 'urban-life' },
{ label: 'Wuxia', value: 'wuxia' },
{ label: 'Xianxia', value: 'xianxia' },
{ label: 'Xuanhuan', value: 'xuanhuan' },
{ label: 'Yaoi', value: 'yaoi' },
{ label: 'Yuri', value: 'yuri' },
],
type: FilterTypes.CheckboxGroup,
},
} satisfies Filters;
}
export default new NovelBuddy();
+443
View File
@@ -0,0 +1,443 @@
import { CheerioAPI, load } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { NovelStatus } from '@libs/novelStatus';
import { Filters, FilterTypes } from '@libs/filterInputs';
class NovelFire implements Plugin.PluginBase {
id = 'novelfire';
name = 'Novel Fire';
version = '1.0.4';
icon = 'src/en/novelfire/icon.png';
site = 'https://novelfire.net/';
async getCheerio(url: string, search: boolean): Promise<CheerioAPI> {
const r = await fetchApi(url);
if (!r.ok && search != true)
throw new Error(
'Could not reach site (' + r.status + ') try to open in webview.',
);
const $ = load(await r.text());
return $;
}
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
let url = this.site + 'search-adv';
if (showLatestNovels) {
url += `?ctgcon=and&totalchapter=0&ratcon=min&rating=0&status=-1&sort=date&tagcon=and&page=${pageNo}`;
} else if (filters) {
const params = new URLSearchParams();
for (const language of filters.language.value) {
params.append('country_id[]', language);
}
params.append('ctgcon', filters.genre_operator.value);
for (const genre of filters.genres.value) {
params.append('categories[]', genre);
}
params.append('totalchapter', filters.chapters.value);
params.append('ratcon', filters.rating_operator.value);
params.append('rating', filters.rating.value);
params.append('status', filters.status.value);
params.append('sort', filters.sort.value);
params.append('page', pageNo.toString());
url += `?${params.toString()}`;
} else {
url += `?ctgcon=and&totalchapter=0&ratcon=min&rating=0&status=-1&sort=rank-top&page=${pageNo}`;
}
const loadedCheerio = await this.getCheerio(url, false);
return loadedCheerio('.novel-item')
.map((index, ele) => {
const novelName =
loadedCheerio(ele).find('.novel-title > a').attr('title') ||
'No Title Found';
const novelCover = loadedCheerio(ele)
.find('.novel-cover > img')
.attr('data-src');
const novelPath = loadedCheerio(ele)
.find('.novel-title > a')
.attr('href');
if (!novelPath) return null;
return {
name: novelName,
cover: novelCover,
path: novelPath.replace(this.site, ''),
};
})
.get()
.filter(novel => novel !== null);
}
async parseChapters(
novelPath: string,
pages: number,
): Promise<Plugin.ChapterItem[]> {
const pagesArray = Array.from({ length: pages }, (_, i) => i + 1);
const allChapters: Plugin.ChapterItem[] = [];
// Function to parse a single page
const parsePage = async (page: number) => {
const url = `${this.site}${novelPath}/chapters?page=${page}`;
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = load(body);
if (loadedCheerio.text().includes('You are being rate limited')) {
throw new NovelFireThrottlingError();
}
const chapters = loadedCheerio('.chapter-list li')
.map((index, ele) => {
const chapterName =
loadedCheerio(ele).find('a').attr('title') || 'No Title Found';
const chapterPath = loadedCheerio(ele).find('a').attr('href');
if (!chapterPath) return null;
return {
name: chapterName,
path: chapterPath.replace(this.site, ''),
};
})
.get()
.filter(chapter => chapter !== null) as Plugin.ChapterItem[];
return chapters;
};
// When pages > ~30, we get rate limited. To mitigate, split into chunks and retry chunk on rate limit with delay.
const chunkSize = 5; // 5 pages per chunk was tested to be a good balance between speed and rate limiting.
const retryCount = 10;
const sleepTime = 3.5; // Rate limit seems to be around ~10s, so usually 3 retries should be enough for another ~30 pages.
const chaptersArray: Plugin.ChapterItem[][] = [];
for (let i = 0; i < pagesArray.length; i += chunkSize) {
const pagesArrayChunk = pagesArray.slice(i, i + chunkSize);
const firstPage = pagesArrayChunk[0];
const lastPage = pagesArrayChunk[pagesArrayChunk.length - 1];
let attempt = 0;
while (attempt < retryCount) {
try {
// Parse all pages in chunk in parallel
const chaptersArrayChunk = await Promise.all(
pagesArrayChunk.map(parsePage),
);
chaptersArray.push(...chaptersArrayChunk);
break;
} catch (err) {
if (err instanceof NovelFireThrottlingError) {
attempt += 1;
console.warn(
`[pages=${firstPage}-${lastPage}] Novel Fire is rate limiting requests. Retry attempt ${attempt + 1} in ${sleepTime} seconds...`,
);
if (attempt === retryCount) {
throw err;
}
// Sleep for X second before retrying
await new Promise(resolve => setTimeout(resolve, sleepTime * 1000));
} else {
throw err;
}
}
}
}
// Merge all chapters into a single array
for (const chapters of chaptersArray) {
allChapters.push(...chapters);
}
return allChapters.length === 0 ? [] : allChapters;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const $ = await this.getCheerio(this.site + novelPath, false);
const baseUrl = this.site;
const novel: Partial<Plugin.SourceNovel> = {
path: novelPath,
};
novel.name =
$('.novel-title').text().trim() ??
$('.cover > img').attr('alt') ??
'No Titled Found';
const coverUrl =
$('.cover > img').attr('data-src') ?? $('.cover > img').attr('src');
if (coverUrl) {
novel.cover = new URL(coverUrl, baseUrl).href;
}
novel.genres = $('.categories .property-item')
.map((i, el) => $(el).text())
.toArray()
.join(',');
let summary = $('.summary .content').text().trim();
if (summary) {
summary = summary.replace('Show More', '');
novel.summary = summary;
} else {
novel.summary = 'No Summary Found';
}
novel.author =
$('.author .property-item > span').text() || 'No Author Found';
const rawStatus =
$('.header-stats .ongoing').text() ||
$('.header-stats .completed').text() ||
'Unknown';
const map: Record<string, string> = {
ongoing: NovelStatus.Ongoing,
hiatus: NovelStatus.OnHiatus,
dropped: NovelStatus.Cancelled,
cancelled: NovelStatus.Cancelled,
completed: NovelStatus.Completed,
unknown: NovelStatus.Unknown,
};
novel.status = map[rawStatus.toLowerCase()] ?? NovelStatus.Unknown;
const totalChapters = $('.header-stats .icon-book-open')
.parent()
.text()
.trim();
const pages = Math.ceil(parseInt(totalChapters) / 100);
novel.chapters = await this.parseChapters(novelPath, pages);
return novel as Plugin.SourceNovel;
}
async parseChapter(chapterPath: string): Promise<string> {
const url = this.site + chapterPath;
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = load(body);
const bloatElements = [
'.box-ads',
'.box-notification',
/^nf/, // Regular expression to match tags starting with 'nf'
];
bloatElements.forEach(tag => {
if (tag instanceof RegExp) {
loadedCheerio('*')
.filter((_, el) =>
tag.test(loadedCheerio(el).prop('tagName')!.toLowerCase()),
)
.remove();
} else {
loadedCheerio(tag).remove();
}
});
return loadedCheerio('#content').html()!;
}
async searchNovels(
searchTerm: string,
page: number,
): Promise<Plugin.NovelItem[]> {
const url = `${this.site}search?keyword=${encodeURIComponent(searchTerm)}&page=${page}`;
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = load(body);
return loadedCheerio('.novel-list.chapters .novel-item')
.map((index, ele) => {
const novelName =
loadedCheerio(ele).find('a').attr('title') || 'No Title Found';
const novelCover = loadedCheerio(ele)
.find('.novel-cover > img')
.attr('src');
const novelPath = loadedCheerio(ele).find('a').attr('href');
if (!novelPath) return null;
return {
name: novelName,
cover: novelCover,
path: novelPath.replace(this.site, ''),
};
})
.get()
.filter(novel => novel !== null);
}
filters = {
sort: {
label: 'Sort Results By',
value: 'rank-top',
options: [
{ label: 'Rank (Top)', value: 'rank-top' },
{ label: 'Rating Score (Top)', value: 'rating-score-top' },
{ label: 'Bookmark Count (Most)', value: 'bookmark' },
{ label: 'Review Count (Most)', value: 'review' },
{ label: 'Title (A>Z)', value: 'abc' },
{ label: 'Title (Z>A)', value: 'cba' },
{ label: 'Last Updated (Newest)', value: 'date' },
{ label: 'Chapter Count (Most)', value: 'chapter-count-most' },
],
type: FilterTypes.Picker,
},
status: {
label: 'Translation Status',
value: '-1',
options: [
{ label: 'All', value: '-1' },
{ label: 'Completed', value: '1' },
{ label: 'Ongoing', value: '0' },
],
type: FilterTypes.Picker,
},
genre_operator: {
label: 'Genres (And/Or)',
value: 'and',
options: [
{ label: 'And', value: 'and' },
{ label: 'Or', value: 'or' },
],
type: FilterTypes.Picker,
},
genres: {
label: 'Genres',
value: [],
options: [
{ label: 'Action', value: '3' },
{ label: 'Adult', value: '28' },
{ label: 'Adventure', value: '4' },
{ label: 'Anime', value: '46' },
{ label: 'Arts', value: '47' },
{ label: 'Comedy', value: '5' },
{ label: 'Drama', value: '24' },
{ label: 'Eastern', value: '44' },
{ label: 'Ecchi', value: '26' },
{ label: 'Fan-fiction', value: '48' },
{ label: 'Fantasy', value: '6' },
{ label: 'Game', value: '19' },
{ label: 'Gender Bender', value: '25' },
{ label: 'Harem', value: '7' },
{ label: 'Historical', value: '12' },
{ label: 'Horror', value: '37' },
{ label: 'Isekai', value: '49' },
{ label: 'Josei', value: '2' },
{ label: 'Lgbt+', value: '45' },
{ label: 'Magic', value: '50' },
{ label: 'Magical Realism', value: '51' },
{ label: 'Manhua', value: '52' },
{ label: 'Martial Arts', value: '15' },
{ label: 'Mature', value: '8' },
{ label: 'Mecha', value: '34' },
{ label: 'Military', value: '53' },
{ label: 'Modern Life', value: '54' },
{ label: 'Movies', value: '55' },
{ label: 'Mystery', value: '16' },
{ label: 'Psychological', value: '9' },
{ label: 'Realistic Fiction', value: '56' },
{ label: 'Reincarnation', value: '43' },
{ label: 'Romance', value: '1' },
{ label: 'School Life', value: '21' },
{ label: 'Sci-fi', value: '20' },
{ label: 'Seinen', value: '10' },
{ label: 'Shoujo', value: '38' },
{ label: 'Shoujo Ai', value: '57' },
{ label: 'Shounen', value: '17' },
{ label: 'Shounen Ai', value: '39' },
{ label: 'Slice of Life', value: '13' },
{ label: 'Smut', value: '29' },
{ label: 'Sports', value: '42' },
{ label: 'Supernatural', value: '18' },
{ label: 'System', value: '58' },
{ label: 'Tragedy', value: '32' },
{ label: 'Urban', value: '63' },
{ label: 'Urban Life', value: '59' },
{ label: 'Video Games', value: '60' },
{ label: 'War', value: '61' },
{ label: 'Wuxia', value: '31' },
{ label: 'Xianxia', value: '23' },
{ label: 'Xuanhuan', value: '22' },
{ label: 'Yaoi', value: '14' },
{ label: 'Yuri', value: '62' },
],
type: FilterTypes.CheckboxGroup,
},
language: {
label: 'Language',
value: [],
options: [
{ label: 'Chinese', value: '1' },
{ label: 'Korean', value: '2' },
{ label: 'Japanese', value: '3' },
{ label: 'English', value: '4' },
],
type: FilterTypes.CheckboxGroup,
},
rating_operator: {
label: 'Rating (Min/Max)',
value: 'min',
options: [
{ label: 'Min', value: 'min' },
{ label: 'Max', value: 'max' },
],
type: FilterTypes.Picker,
},
rating: {
label: 'Rating',
value: '0',
options: [
{ label: 'All', value: '0' },
{ label: '1', value: '1' },
{ label: '2', value: '2' },
{ label: '3', value: '3' },
{ label: '4', value: '4' },
{ label: '5', value: '5' },
],
type: FilterTypes.Picker,
},
chapters: {
label: 'Chapters',
value: '0',
options: [
{ label: 'All', value: '0' },
{ label: '<50', value: '1,49' },
{ label: '50-100', value: '50,100' },
{ label: '100-200', value: '100,200' },
{ label: '200-500', value: '200,500' },
{ label: '500-1000', value: '500,1000' },
{ label: '>1000', value: '1001,1000000' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
}
export default new NovelFire();
// Custom error for when Novel Fire is rate limiting requests
class NovelFireThrottlingError extends Error {
constructor(message = 'Novel Fire is rate limiting requests') {
super(message);
this.name = 'NovelFireError';
}
}
+123
View File
@@ -0,0 +1,123 @@
import { load as parseHTML } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { defaultCover } from '@libs/defaultCover';
class NovelHall implements Plugin.PluginBase {
id = 'novelhall';
name = 'Novel Hall';
version = '1.0.2';
icon = 'src/en/novelhall/icon.png';
site = 'https://novelhall.com/';
async popularNovels(page: number): 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): Promise<Plugin.NovelItem[]> {
const url = `${this.site}index.php?s=so&module=book&keyword=${encodeURIComponent(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();
+219
View File
@@ -0,0 +1,219 @@
import { load as parseHTML } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { defaultCover } from '@libs/defaultCover';
import dayjs from 'dayjs';
import { storage } from '@/lib/storage';
import { NovelStatus } from '@libs/novelStatus';
class Novelight implements Plugin.PagePlugin {
id = 'novelight';
name = 'Novelight';
version = '1.1.1';
icon = 'src/en/novelight/icon.png';
site = 'https://novelight.net/';
hideLocked = storage.get('hideLocked');
pluginSettings = {
hideLocked: {
value: '',
label: 'Hide locked chapters',
type: 'Switch',
},
};
async popularNovels(page: number): Promise<Plugin.NovelItem[]> {
const url = `${this.site}catalog/?ordering=popularity&page=${page}`;
const body = await fetchApi(url).then(r => r.text());
const loadedCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
loadedCheerio('a.item').each((idx, ele) => {
const novelName = loadedCheerio(ele).find('div.title').text().trim();
const novelUrl = ele.attribs.href;
const bareNovelCover = loadedCheerio(ele).find('img').attr('src');
const novelCover = bareNovelCover
? this.site + bareNovelCover
: defaultCover;
if (!novelUrl) return;
const novel = {
name: novelName,
cover: novelCover ?? defaultCover,
path: novelUrl.replace('/', ''),
};
novels.push(novel);
});
return novels;
}
async parseNovel(
novelPath: string,
): Promise<Plugin.SourceNovel & { totalPages: number }> {
const body = await fetchApi(this.site + novelPath).then(r => r.text());
const loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel & { totalPages: number } = {
path: novelPath,
name: loadedCheerio('h1').text() || 'Untitled',
cover: this.site + loadedCheerio('.poster > img').attr('src'),
summary: loadedCheerio('section.text-info.section > p').text(),
totalPages: loadedCheerio('#select-pagination-chapter > option').length,
chapters: [],
};
const info = loadedCheerio('div.mini-info > .item').toArray();
let status = '';
let translation = '';
for (const child of info) {
const type = loadedCheerio(child).find('.sub-header').text().trim();
if (type === 'Status') {
status = loadedCheerio(child).find('div.info').text().trim();
}
if (type === 'Translation') {
translation = loadedCheerio(child).find('div.info').text().trim();
}
if (type === 'Author') {
novel.author = loadedCheerio(child).find('div.info').text().trim();
}
if (type === 'Genres') {
novel.genres = loadedCheerio(child)
.find('div.info > a')
.map((i, el) => loadedCheerio(el).text())
.toArray()
.join(', ');
}
}
if (status === 'cancelled') novel.status = NovelStatus.Cancelled;
else if (status === 'releasing' || translation === 'ongoing')
novel.status = NovelStatus.Ongoing;
else if (status === 'completed' && translation === 'completed')
novel.status = NovelStatus.Completed;
return novel;
}
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
const rawBody = await fetchApi(this.site + novelPath).then(r => r.text());
const csrftoken = rawBody?.match(/window\.CSRF_TOKEN = "([^"]+)"/)?.[1];
const bookId = rawBody?.match(/const OBJECT_BY_COMMENT = ([0-9]+)/)?.[1];
const totalPages = parseInt(
rawBody
?.match(/<option value="([0-9]+)"/g)
?.at(-1)
?.match(/([0-9]+)/)?.[1] ?? '1',
);
const chaptersRaw = await fetchApi(
`${this.site}/book/ajax/chapter-pagination?csrfmiddlewaretoken=${csrftoken}&book_id=${bookId}&page=${totalPages - parseInt(page) + 1}`,
{
headers: {
'Host': this.site.replace('https://', '').replace('/', ''),
'Referer': this.site + novelPath,
'X-Requested-With': 'XMLHttpRequest',
},
},
)
.then(r => r.json())
.then(r => r.html);
const chapter: Plugin.ChapterItem[] = [];
parseHTML('<html>' + chaptersRaw + '</html>')('a').each((idx, ele) => {
const title = parseHTML(ele)('.title').text().trim();
const isLocked = !!parseHTML(ele)('.cost').text().trim();
if (this.hideLocked && isLocked) return;
let date;
try {
date = dayjs(
parseHTML(ele)('.date').text().trim(),
'DD.MM.YYYY',
).toISOString();
} catch (error) {}
const chapterName = isLocked ? '🔒 ' + title : title;
const chapterUrl = ele.attribs.href;
chapter.push({
name: chapterName,
path: chapterUrl,
page: page,
releaseTime: date,
});
});
const chapters = chapter.reverse();
return { chapters };
}
async parseChapter(chapterPath: string): Promise<string> {
const rawBody = await fetchApi(this.site + chapterPath).then(r => {
const res = r.text();
return res;
});
const csrftoken = rawBody?.match(/window\.CSRF_TOKEN = "([^"]+)"/)?.[1];
const chapterId = rawBody?.match(/const CHAPTER_ID = "([0-9]+)/)?.[1];
let className;
const body = await fetchApi(
this.site + 'book/ajax/read-chapter/' + chapterId,
{
method: 'GET',
headers: {
Cookie: 'csrftoken=' + csrftoken,
Referer: this.site + chapterPath,
'X-Requested-With': 'XMLHttpRequest',
},
},
).then(async r => {
const res = await r.json();
className = res.class;
return res.content;
});
const loadedCheerio = parseHTML(body);
const chapterText = loadedCheerio('.' + className).html() || '';
return chapterText.replace(
/class="advertisment"/g,
'style="display:none;"',
);
}
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
const url = `${this.site}catalog/?search=${encodeURIComponent(searchTerm)}`;
const body = await fetchApi(url).then(r => r.text());
const loadedCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
loadedCheerio('a.item').each((idx, ele) => {
const novelName = loadedCheerio(ele).find('div.title').text().trim();
const novelUrl = ele.attribs.href;
const bareNovelCover = loadedCheerio(ele).find('img').attr('src');
const novelCover = bareNovelCover
? this.site + bareNovelCover
: defaultCover;
if (!novelUrl) return;
const novel = {
name: novelName,
cover: novelCover ?? defaultCover,
path: novelUrl.replace('/', ''),
};
novels.push(novel);
});
return novels;
}
}
export default new Novelight();
File diff suppressed because it is too large Load Diff
+447
View File
@@ -0,0 +1,447 @@
import { Parser } from 'htmlparser2';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { Filters, FilterTypes } from '@libs/filterInputs';
class PawRead implements Plugin.PluginBase {
id = 'pawread';
name = 'PawRead';
version = '2.1.0';
icon = 'src/en/pawread/icon.png';
site = 'https://m.pawread.com/';
parseNovels(html: string) {
const novels: Plugin.NovelItem[] = [];
let tempNovel: Partial<Plugin.NovelItem> = {};
let state: ParsingState = ParsingState.Idle;
const parser = new Parser({
onopentag(name, attribs) {
if (
attribs.class &&
(attribs.class.includes('list-comic') ||
attribs.class.includes('itemBox'))
) {
state = ParsingState.Novel;
}
if (state !== ParsingState.Novel) return;
switch (name) {
case 'a':
if (attribs.class === 'txtA' || attribs.class === 'title') {
tempNovel.path = attribs.href.split('/').slice(1, 3).join('/');
state = ParsingState.NovelName;
}
break;
case 'img':
tempNovel.cover = attribs.src;
break;
}
},
ontext(text) {
if (state === ParsingState.NovelName) {
tempNovel.name = (tempNovel.name || '') + text;
}
},
onclosetag(name) {
if (name === 'a') {
if (tempNovel.name && tempNovel.cover) {
novels.push(tempNovel as Plugin.NovelItem);
tempNovel = {};
state = ParsingState.Idle;
}
}
},
});
parser.write(html);
parser.end();
return novels;
}
async popularNovels(
page: number,
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
let link = `${this.site}list/`;
const filterValues = [
filters.genre.value,
filters.status.value,
filters.lang.value,
].filter(value => value !== '');
if (filterValues.length > 0) {
link += filterValues.join('-') + '/';
}
link += (filters.order.value ? '-' : '') + filters.sort.value;
link += `/?page=${page}`;
const body = await fetchApi(link).then(r => r.text());
return this.parseNovels(body);
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const slash = novelPath.endsWith('/') ? '' : '/';
const result = await fetchApi(this.site + novelPath + slash);
const body = await result.text();
const novel: Partial<Plugin.SourceNovel> = {
path: novelPath,
};
let depth = 0;
let state = ParsingState.Idle;
let tempChapter: Partial<Plugin.ChapterItem> = {};
const chapter: Plugin.ChapterItem[] = [];
const summaryParts: string[] = [];
const genreArray: string[] = [];
const parser = new Parser({
onopentag(name, attribs) {
switch (name) {
case 'div':
if (attribs.id === 'Cover') {
state = ParsingState.Cover;
}
if (attribs.class?.includes('item-box')) {
state = ParsingState.Chapter;
const path = attribs.onclick.match(/\d+/)![0];
tempChapter.path = `${novelPath}${slash}${path}.html`;
return;
}
if (state === ParsingState.Chapter) depth++;
break;
case 'img':
if (state === ParsingState.Cover) {
novel.name = attribs.title;
novel.cover = attribs.src;
}
break;
case 'p':
if (attribs.class === 'txtItme') {
if (!novel.status) {
state = ParsingState.Status;
} else if (!novel.author) {
state = ParsingState.Author;
}
} else if (attribs.id === 'full-des') {
state = ParsingState.Summary;
}
break;
case 'br':
summaryParts.push('\n');
break;
case 'a':
if (attribs.class?.includes('btn-default')) {
state = ParsingState.Genres;
}
break;
case 'span':
if (state === ParsingState.Chapter) depth++;
if (depth === 2) {
state = ParsingState.ChapterName;
} else if (depth === 1) {
state = ParsingState.ChapterTime;
}
break;
}
},
ontext(text) {
switch (state) {
case ParsingState.Status:
novel.status = (novel.status || '') + text.trim();
break;
case ParsingState.Author:
novel.author = (novel.author || '') + text.trim();
break;
case ParsingState.Genres:
genreArray.push(text.trim());
break;
case ParsingState.Summary:
summaryParts.push(text);
break;
case ParsingState.ChapterName:
tempChapter.name = (tempChapter.name || '') + text.trim();
break;
case ParsingState.ChapterTime:
if (text?.includes('Advanced')) return;
const releaseDate = text.split('.').map(x => Number(x));
if (releaseDate.length === 3) {
tempChapter.releaseTime = new Date(
releaseDate[0],
releaseDate[1] - 1,
releaseDate[2],
).toISOString();
}
break;
}
},
onclosetag(name) {
switch (name) {
case 'div':
if (state === ParsingState.Cover) {
state = ParsingState.Idle;
}
if (state === ParsingState.Chapter) {
depth--;
if (depth < 0) {
if (
tempChapter.path &&
tempChapter.name &&
tempChapter.releaseTime
) {
chapter.push(tempChapter as Plugin.ChapterItem);
}
tempChapter = {};
depth = 0;
state = ParsingState.Idle;
}
}
break;
case 'p':
if (
state === ParsingState.Status ||
state === ParsingState.Author ||
state === ParsingState.Summary
) {
state = ParsingState.Idle;
}
break;
case 'a':
if (state === ParsingState.Genres) {
state = ParsingState.Idle;
}
break;
case 'span':
if (
state === ParsingState.ChapterName ||
state === ParsingState.ChapterTime
) {
state = ParsingState.Chapter;
depth--;
}
break;
}
},
onend() {
novel.genres = genreArray.join(', ');
novel.summary = summaryParts.join('');
novel.chapters = chapter;
},
});
parser.write(body);
parser.end();
return novel as Plugin.SourceNovel;
}
async parseChapter(chapterPath: string): Promise<string> {
const html = await fetchApi(this.site + chapterPath).then(r => r.text());
let depth = 0;
let state: ParsingState = ParsingState.Idle;
const chapterHtml: string[] = [];
type EscapeChar = '&' | '<' | '>' | '"' | "'" | ' ';
const escapeRegex = /[&<>"' ]/g;
const escapeMap: Record<EscapeChar, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
' ': '&nbsp;',
};
const escapeHtml = (text: string): string =>
text.replace(escapeRegex, char => escapeMap[char as EscapeChar]);
const parser = new Parser({
onopentag(name, attribs) {
switch (state) {
case ParsingState.Idle:
if (name === 'div' && attribs.class === 'main') {
state = ParsingState.Chapter;
depth++;
}
break;
case ParsingState.Chapter:
if (name === 'div') depth++;
break;
default:
return;
}
if (state === ParsingState.Chapter) {
const attr = Object.keys(attribs).map(key => {
const value = attribs[key].replace(/"/g, '&quot;');
return ` ${key}="${value}"`;
});
chapterHtml.push(`<${name}${attr.join('')}>`);
}
},
ontext(data) {
if (state === ParsingState.Chapter) {
const text = escapeHtml(data);
const icontains = ['pawread', 'tinyurl', 'bit.ly'];
if (icontains.some(pattern => text?.includes(pattern))) {
state = ParsingState.Hidden;
chapterHtml.pop();
} else {
chapterHtml.push(text);
}
}
},
onclosetag(name) {
switch (state) {
case ParsingState.Chapter:
if (!parser['isVoidElement'](name)) {
chapterHtml.push(`</${name}>`);
}
if (name === 'div') depth--;
if (depth === 0) {
state = ParsingState.Stopped;
}
break;
case ParsingState.Hidden:
state = ParsingState.Chapter;
break;
}
},
});
parser.write(html);
parser.end();
return chapterHtml.join('');
}
async searchNovels(
searchTerm: string,
page: number,
): Promise<Plugin.NovelItem[]> {
const params = new URLSearchParams({
keywords: searchTerm,
page: page.toString(),
});
const result = await fetchApi(`${this.site}search/?${params.toString()}`);
const body = await result.text();
return this.parseNovels(body);
}
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();
enum ParsingState {
Idle,
Cover,
Genres,
Author,
Status,
Hidden,
Summary,
Stopped,
Chapter,
ChapterName,
ChapterTime,
NovelName,
Novel,
}
+195
View File
@@ -0,0 +1,195 @@
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { Filters, FilterTypes } from '@libs/filterInputs';
import { CheerioAPI, load as parseHTML } from 'cheerio';
import { NovelStatus } from '@libs/novelStatus';
class Rainofsnow implements Plugin.PagePlugin {
id = 'rainofsnow';
name = 'Rainofsnow';
icon = 'src/en/rainofsnow/icon.png';
site = 'https://rainofsnow.com/';
version = '1.1.2';
parseNovels(loadedCheerio: CheerioAPI) {
const novels: Plugin.NovelItem[] = [];
loadedCheerio('.minbox').each((index, element) => {
const name = loadedCheerio(element).find('h3').text();
const cover = loadedCheerio(element).find('img').attr('data-src');
const path = loadedCheerio(element)
.find('h3 > a')
.attr('href')
?.replace(this.site, '')
.replace(/\/+$/, '');
if (!path) {
return;
}
novels.push({ name, cover, path });
});
return novels;
}
async popularNovels(
pageNo: number,
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
const url = this.site + 'novels/page/' + pageNo + filters.genre.value;
const body = await fetchApi(url).then(res => res.text());
const loadedCheerio = parseHTML(body);
return this.parseNovels(loadedCheerio);
}
async parseNovel(
novelPath: string,
): Promise<Plugin.SourceNovel & { totalPages: number }> {
const novel: Plugin.SourceNovel & { totalPages: number } = {
path: novelPath,
name: '',
totalPages: 0,
};
const body = await fetchApi(this.site + novelPath).then(res => res.text());
const loadedCheerio = parseHTML(body);
novel.name = loadedCheerio('.text h2').text().trim();
novel.cover = loadedCheerio('.imagboca1 img').attr('data-src');
novel.summary = loadedCheerio('#synop').text().trim();
novel.genres = loadedCheerio('span:contains("Genre(s)")')
.next()
.text()
.trim();
novel.author = loadedCheerio('span:contains("Author")').next().text();
let x = 1;
loadedCheerio('.page-numbers li').each((i, el) => {
const num = loadedCheerio(el).find('a').text().trim().match(/(\d+)/);
const n = Number(num?.[1] || '0');
if (n > x) {
x = n;
}
});
novel.totalPages = x;
novel.chapters = this.parseChapters(loadedCheerio);
novel.status = NovelStatus.Unknown;
return novel;
}
// parse paged chapters
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
const url = this.site + novelPath + '/page/' + page + '/#chapter';
const body = await fetchApi(url).then(res => res.text());
const loadedCheerio = parseHTML(body);
const chapters = this.parseChapters(loadedCheerio);
return { chapters };
}
// helper to parse a novel
parseChapters(loadedCheerio: CheerioAPI) {
const chapter: Plugin.ChapterItem[] = [];
loadedCheerio('#chapter .march1 li').each((i, el) => {
const path = loadedCheerio(el)
.find('a')
.attr('href')
?.slice(this.site.length);
if (!path) return;
const name = loadedCheerio(el).find('.chapter').first().text().trim();
const date = loadedCheerio(el).find('small').text().trim().toLowerCase();
const months = [
'jan',
'feb',
'mar',
'apr',
'may',
'jun',
'jul',
'aug',
'sep',
'oct',
'nov',
'dec',
];
const regex = /([a-z]+) (..?), (.+)/;
const [, monthName, day, year] = regex.exec(date) || [];
const month = months.indexOf(monthName.slice(0, 3));
const releaseTime =
monthName && month !== -1
? new Date(+year, month, +day).toISOString()
: null;
chapter.push({
name,
path,
releaseTime,
});
});
return chapter;
}
async parseChapter(chapterPath: string): Promise<string> {
// parse chapter text here
const result = await fetch(this.site + chapterPath);
const body = await result.text();
const loadedCheerio = parseHTML(body);
const chapterName = loadedCheerio('.content > h2').text();
const chapterText = loadedCheerio('.content').html();
if (!chapterText) return '';
return chapterName + '\n' + chapterText;
}
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
// get novels using the search term
// no page number, infinite scroll
const newSearch = searchTerm.replace(/\s+/g, '+');
const url = this.site + '?s=' + encodeURIComponent(newSearch);
const result = await fetch(url);
const body = await result.text();
const loadedCheerio = parseHTML(body);
return this.parseNovels(loadedCheerio);
}
filters = {
genre: {
value: '',
label: 'Filter By',
options: [
{ label: 'All', value: '' },
{ label: 'Action', value: '?n_orderby=16' },
{ label: 'Adventure', value: '?n_orderby=11' },
{ label: 'Angst', value: '?n_orderby=776' },
{ label: 'Chinese', value: '?n_orderby=342' },
{ label: 'Comedy', value: '?n_orderby=13' },
{ label: 'Drama', value: '?n_orderby=3' },
{ label: 'Fantasy', value: '?n_orderby=7' },
{ label: 'Japanese', value: '?n_orderby=343' },
{ label: 'Korean', value: '?n_orderby=341' },
{ label: 'Mature', value: '?n_orderby=778' },
{ label: 'Mystery', value: '?n_orderby=12' },
{ label: 'Original Novel', value: '?n_orderby=339' },
{ label: 'Psychological', value: '?n_orderby=769' },
{ label: 'Romance', value: '?n_orderby=5' },
{ label: 'Sci-fi', value: '?n_orderby=14' },
{ label: 'Slice of Life', value: '?n_orderby=779' },
{ label: 'Supernatural', value: '?n_orderby=780' },
{ label: 'Tragedy', value: '?n_orderby=777' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
}
export default new Rainofsnow();
+212
View File
@@ -0,0 +1,212 @@
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { Filters } from '@libs/filterInputs';
import { CheerioAPI, load as loadCheerio } from 'cheerio';
import { defaultCover } from '@libs/defaultCover';
class ReadFromPlugin implements Plugin.PluginBase {
id = 'readfrom';
name = 'Read From Net';
icon = 'src/en/readfrom/icon.png';
site = 'https://readfrom.net';
version = '1.0.2';
filters: Filters | undefined = undefined;
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
//flag indicates whether access to LocalStorage, SesesionStorage is required.
webStorageUtilized?: boolean;
loadedNovelCache: (Plugin.NovelItem & {
summary: string;
genres: string;
author: string;
})[] = [];
parseNovels(
loadedCheerio: CheerioAPI,
isSearch?: boolean,
): (Plugin.NovelItem & {
summary: string;
genres: string;
author: string;
})[] {
const ret = loadedCheerio(
(isSearch ? 'div.text' : '#dle-content') + ' > article.box',
)
.map((i, el) => {
const summary = loadedCheerio(el).find(
isSearch ? 'div.text5' : 'div.text3',
)[0];
loadedCheerio(summary).find('.coll-ellipsis').remove();
loadedCheerio(summary).find('a').remove();
return {
name: loadedCheerio(el).find('h2.title').text().trim(),
path: loadedCheerio(el)
.find('h2.title > a')
.attr('href')!
.replace('https://readfrom.net/', '')
.replace(/^\//, ''),
cover: loadedCheerio(el).find('img').attr('src') || defaultCover,
summary:
loadedCheerio(summary).text().trim() +
loadedCheerio(summary).find('span.coll-hidden').text(),
genres: loadedCheerio(el)
.find(isSearch ? 'h5.title > a' : 'h2 > a')
.filter((i, el) => el.attribs['title']?.startsWith?.('Genre - '))
.map((i, el) => loadedCheerio(el).text())
.toArray()
.join(', '),
author: isSearch
? loadedCheerio(el)
.find('h5.title > a')
.filter((i, el) =>
el.attribs['title']?.startsWith?.('Book author - '),
)
.text()
: loadedCheerio(el).find('h4 > a').text(),
};
})
.toArray();
this.loadedNovelCache.push(...ret);
while (this.loadedNovelCache.length > 100) {
this.loadedNovelCache.shift();
}
return ret;
}
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
) {
const type = showLatestNovels ? 'last_added_books' : 'allbooks';
const res = await fetchApi(
'https://readfrom.net/' + type + '/page/' + pageNo + '/',
);
const text = await res.text();
return this.parseNovels(loadCheerio(text));
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const data = await fetchApi('https://readfrom.net/' + novelPath);
const text = await data.text();
const loadedCheerio = loadCheerio(text);
const novel: Plugin.SourceNovel = {
path: novelPath,
name: 'Untitled',
};
novel.name = loadedCheerio('center > h2.title')
.text()
.split(', \n\n')[0]
.trim();
novel.cover =
loadedCheerio('article.box > div > center > div > a > img').attr('src') ||
defaultCover;
novel.chapters = loadedCheerio(loadedCheerio('div.pages').get()[0])
.find('> a')
.map((i, el) => {
return {
name: loadedCheerio(el).text().trim(),
path: loadedCheerio(el)
.attr('href')!
.replace('https://readfrom.net/', '')
.replace(/^\//, ''),
// releaseTime: '',
chapterNumber: i + 2,
};
})
.toArray();
novel.chapters.unshift({
name: '1',
path: novelPath,
chapterNumber: 1,
});
let moreNovelInfo = this.loadedNovelCache.find(
novel => novel.path === novelPath,
);
if (!moreNovelInfo)
moreNovelInfo = (await this.searchNovels(novel.name, 1)).find(
novel => novel.path === novelPath,
);
if (moreNovelInfo) {
novel.summary = moreNovelInfo.summary;
novel.genres = moreNovelInfo.genres;
novel.author = moreNovelInfo.author;
}
const seriesElm = loadedCheerio('center > b:has(a)').filter((i, el) =>
loadedCheerio(el).find('a').attr('href')!.startsWith('/series.html'),
)[0];
if (seriesElm) {
const seriesText = loadedCheerio(seriesElm).text().trim();
novel.summary = seriesText + '\n\n' + novel.summary;
}
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const data = await fetchApi('https://readfrom.net/' + chapterPath);
const text = await data.text();
const loadedCheerio = loadCheerio(text);
loadedCheerio('#textToRead > span:empty').remove();
loadedCheerio('#textToRead > center').remove();
const textToRead = loadedCheerio('#textToRead');
let paragraph: string[] = [];
const chapterHtml: string[] = [];
textToRead.contents().each((_, element) => {
switch (element.type) {
case 'text':
const content = element.data.trim();
if (content) {
paragraph.push(content);
}
break;
case 'tag':
if (paragraph.length > 0) {
chapterHtml.push(`<p>${paragraph.join(' ')}</p>`);
paragraph = [];
}
if (element.tagName !== 'br') {
chapterHtml.push(loadedCheerio.html(element));
}
break;
}
});
// Close any remaining paragraph
if (paragraph.length > 0) {
chapterHtml.push(`<p>${paragraph.join(' ')}</p>`);
}
return chapterHtml.join('');
}
async searchNovels(searchTerm: string, pageNo: number) {
if (pageNo !== 1) return [];
const res = await fetchApi(
'https://readfrom.net/build_in_search/?q=' +
encodeURIComponent(searchTerm),
);
const text = await res.text();
return this.parseNovels(loadCheerio(text), true);
}
resolveUrl = (path: string, isNovel?: boolean) => this.site + '/' + path;
}
export default new ReadFromPlugin();
+190
View File
@@ -0,0 +1,190 @@
import { load as parseHTML } from 'cheerio';
import { isUrlAbsolute } from '@libs/isAbsoluteUrl';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { Filters, FilterTypes } from '@libs/filterInputs';
import dayjs from 'dayjs';
class ReadLiteNovel implements Plugin.PluginBase {
id = 'rln.app';
name = 'ReadLiteNovel';
version = '1.0.1';
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): Promise<Plugin.NovelItem[]> {
const url =
this.site +
`/search/autocomplete?dataType=json&query=${encodeURIComponent(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();
+128
View File
@@ -0,0 +1,128 @@
import { fetchApi } from '@libs/fetch';
import type { Plugin } from '@/types/plugin';
const API_BASE = 'https://api.reaperscans.com';
const MEDIA_BASE = 'https://media.reaperscans.com/file/4SRBHm/';
type ApiResponse<T> = {
data: T;
meta: {
total: number;
per_page: number;
current_page: number;
last_page: number;
};
};
type ReaperNovel = {
title: string;
thumbnail: string;
series_slug: string;
};
class ReaperScans implements Plugin.PluginBase {
id = 'reaperscans.com';
name = 'Reaper Scans';
version = '1.0.0';
icon = 'src/en/reaperscans/icon.png';
site = 'https://reaperscans.com';
async popularNovels(page: number): Promise<Plugin.NovelItem[]> {
return this.query(page);
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const novelResp = await fetchApi(`${API_BASE}/series/${novelPath}`);
const novel: {
title: string;
thumbnail: string;
description: string;
tags: string[];
rating: number;
status: string;
alternative_names: string;
author: string;
studio: string;
} = await novelResp.json();
const chaptersResp = await fetchApi(
`${API_BASE}/chapters/${novelPath}?perPage=${Number.MAX_SAFE_INTEGER}`,
);
const chapters: {
chapter_slug: string;
chapter_name: string;
index: string;
created_at: string;
}[] = (await chaptersResp.json()).data;
return {
name: novel.title,
cover: this.getCoverUrl(novel.thumbnail),
author: novel.author,
artist: novel.studio,
status: novel.status,
rating: novel.rating,
summary: novel.description,
genres: novel.tags.join(','),
path: novelPath,
chapters: chapters.reverse().map(chapter => ({
path: `${novelPath}/${chapter.chapter_slug}`,
name: chapter.chapter_name,
chapterNumber: Number.parseFloat(chapter.index),
releaseTime: chapter.created_at.substring(0, 10),
})),
};
}
async parseChapter(chapterPath: string): Promise<string> {
const result = await fetchApi(`${this.site}/series/${chapterPath}`, {
headers: { RSC: '1' },
});
const body = await result.text();
return this.extractChapterContent(body);
}
private extractChapterContent(chapter: string): string {
const lines = chapter.split('\n');
const start = lines.findIndex(line => line.includes('<p'));
const prefix = lines[start].substring(0, lines[start].indexOf('<'));
const commonPrefix = prefix.substring(
prefix.indexOf(':'),
prefix.indexOf(','),
);
const end = lines.lastIndexOf(commonPrefix);
const content = lines.slice(start, end).join('\n');
const deduplicated = content.split(commonPrefix)[1];
return deduplicated.substring(
deduplicated.indexOf('<'),
deduplicated.lastIndexOf('>') + 1,
);
}
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
return this.query(1, searchTerm);
}
private async query(page = 1, search = ''): Promise<Plugin.NovelItem[]> {
const link = `${API_BASE}/query?page=${page}&perPage=20&series_type=Novel&query_string=${search}&order=desc&orderBy=created_at&adult=true&status=All&tags_ids=[]`;
const result = await fetchApi(link);
const json: ApiResponse<ReaperNovel[]> = await result.json();
return json.data.map(novel => ({
name: novel.title,
cover: novel.thumbnail.startsWith('novels/')
? MEDIA_BASE + novel.thumbnail
: novel.thumbnail,
path: novel.series_slug,
}));
}
private getCoverUrl(thumbnail: string): string {
return thumbnail.startsWith('novels/') ? MEDIA_BASE + thumbnail : thumbnail;
}
}
export default new ReaperScans();
+427
View File
@@ -0,0 +1,427 @@
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { Filters } from '@libs/filterInputs';
import { load as loadCheerio } from 'cheerio';
import { defaultCover } from '@libs/defaultCover';
import { NovelItem } from '../../test_web/static/js';
import { NovelStatus } from '@libs/novelStatus';
type 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 (const item of this.haystack) {
for (const 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('');
const indexes: number[][] = [];
const idxFL = this.idxFirstLetter(item, query);
for (const 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];
}
const res_a = a[a.length - 1] - a[0];
const 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.2';
imageRequestInit: Plugin.ImageRequestInit = {
headers: {
Referer: 'https://re-library.com/',
},
};
private searchFunc = new FuzzySearch<Plugin.NovelItem>(item => [item.name], {
sort: true,
caseSensitive: false,
});
private async popularNovelsInner(url: string): Promise<Plugin.NovelItem[]> {
const novels: Plugin.NovelItem[] = [];
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = loadCheerio(body);
loadedCheerio('.entry-content > ol > li').each((_i, el) => {
const 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('data-cfsrc') ||
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 fetchApi(url);
const body = await result.text();
const loadedCheerio = loadCheerio(body);
loadedCheerio('article.type-page.page').each((_i, el) => {
const 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 > a >img')
.attr('data-cfsrc') ||
loadedCheerio(el)
.find('.entry-content > 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;
}
async popularNovels(
pageNo: number,
{ showLatestNovels }: Plugin.PopularNovelsOptions<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> {
const 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 fetchApi(`${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 img').attr('data-cfsrc') ||
loadedCheerio('.entry-content > table img').attr('src') ||
defaultCover;
novel.status = NovelStatus.Unknown;
loadedCheerio('.entry-content > table > tbody > tr > td > p').each(
function (_i, el) {
// Handle the novel status
// Sadly some novels just state the status inside the summary...
if (
loadedCheerio(el)
.find('strong')
.text()
.toLowerCase()
.trim()
.startsWith('status')
) {
loadedCheerio(el).find('strong').remove();
const status = loadedCheerio(el).text().toLowerCase().trim();
if (status.includes('on-going')) {
novel.status = NovelStatus.Ongoing;
} else if (status.includes('completed')) {
novel.status = NovelStatus.Completed;
} else if (status.includes('hiatus')) {
novel.status = NovelStatus.OnHiatus;
} else if (status.includes('cancelled')) {
novel.status = NovelStatus.Cancelled;
} else {
novel.status = loadCheerio(el).text();
}
}
// Handle the genres
else if (
loadedCheerio(el)
.find('strong')
.text()
.toLowerCase()
.trim()
.startsWith('Category')
) {
loadedCheerio(el).find('strong').remove();
novel.genres = loadedCheerio(el).text();
}
},
);
novel.summary = loadedCheerio(
'.entry-content > div.su-box > div.su-box-content',
).text();
const chapters: Plugin.ChapterItem[] = [];
let chapter_idx = 0;
loadedCheerio('.entry-content > div.su-accordion').each((_i1, el) => {
loadedCheerio(el)
.find('li > 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 fetchApi(`${this.site}/${chapterPath}`);
const body = await result.text();
const loadedCheerio = loadCheerio(body);
const entryContent = loadedCheerio('.entry-content');
const pageLinkHr = entryContent.find('.PageLink + hr').first();
if (pageLinkHr.length) {
// Remove all previous siblings before the .PageLink + hr
let prev = pageLinkHr.prev();
while (prev.length) {
prev.remove();
prev = pageLinkHr.prev();
}
const pageLink = pageLinkHr.prev('.PageLink');
if (pageLink.length) {
pageLink.remove();
}
pageLinkHr.next().remove();
pageLinkHr.remove();
}
// Find the first <hr> followed by a .PageLink and remove everything after
const hrAfterPageLink = entryContent.find('hr + .PageLink').first();
if (hrAfterPageLink.length) {
let next = hrAfterPageLink.next();
while (next.length) {
const temp = next.next();
next.remove();
next = temp;
}
hrAfterPageLink.prev().remove();
hrAfterPageLink.remove();
}
return entryContent.html() || '';
}
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 [];
const novels: Plugin.NovelItem[] = [];
const req = await fetchApi(`${this.site}/translations/`);
const body = await req.text();
const loadedCheerio = loadCheerio(body);
loadedCheerio('article article').each((_i, el) => {
const e = loadedCheerio(el);
if (e.find('a').attr('href') && e.find('a').text()) {
novels.push({
name: e.find('h4').text(),
path: e.find('a').attr('href')?.replace(this.site, '') || '',
cover:
e.find('img').attr('data-cfsrc') ||
e.find('img').attr('src') ||
defaultCover,
});
}
});
this.searchFunc.setHaystack(novels);
return this.searchFunc.search(searchTerm);
}
}
export default new ReLibraryPlugin();
+952
View File
@@ -0,0 +1,952 @@
import { Parser } from 'htmlparser2';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { Filters, FilterTypes } from '@libs/filterInputs';
import { NovelStatus } from '@libs/novelStatus';
import { isUrlAbsolute } from '@libs/isAbsoluteUrl';
class RoyalRoad implements Plugin.PluginBase {
id = 'royalroad';
name = 'Royal Road';
version = '2.2.3';
icon = 'src/en/royalroad/icon.png';
site = 'https://www.royalroad.com/';
parseNovels(html: string) {
const baseUrl = this.site;
const novels: Plugin.NovelItem[] = [];
let tempNovel: Partial<Plugin.NovelItem> = {};
let state: ParsingState = ParsingState.Idle;
const parser = new Parser({
onopentag(name, attribs) {
if (attribs['class']?.includes('fiction-list-item')) {
state = ParsingState.Novel;
}
if (state !== ParsingState.Novel) return;
switch (name) {
case 'a':
if (attribs['href']) {
tempNovel.path = attribs['href'].split('/').slice(1, 3).join('/');
}
break;
case 'img':
if (attribs['src']) {
tempNovel.name = attribs['alt'] || '';
tempNovel.cover = !isUrlAbsolute(attribs['src'])
? baseUrl + attribs['src'].slice(1)
: attribs['src'];
}
break;
}
},
onclosetag(name) {
if (name === 'figure') {
if (tempNovel.path && tempNovel.name) {
novels.push(tempNovel as Plugin.NovelItem);
tempNovel = {};
}
state = ParsingState.Idle;
}
},
});
parser.write(html);
parser.end();
return novels;
}
async popularNovels(
page: number,
{
filters,
showLatestNovels,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
const params = new URLSearchParams({
page: page.toString(),
});
if (showLatestNovels) {
params.append('orderBy', 'last_update');
}
if (!filters) filters = this.filters || {};
for (const key in filters) {
if (filters[key as keyof typeof filters].value === '') continue;
if (key === 'genres' || key === 'tags' || key === 'content_warnings') {
if (filters[key].value.include) {
for (const include of filters[key].value.include) {
params.append('tagsAdd', include);
}
}
if (filters[key].value.exclude) {
for (const exclude of filters[key].value.exclude) {
params.append('tagsRemove', exclude);
}
}
} else {
params.append(key, String(filters[key as keyof typeof filters].value));
}
}
const link = `${this.site}fictions/search?${params.toString()}`;
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: Partial<Plugin.SourceNovel> = {
path: novelPath,
};
const baseUrl = this.site;
let state: ParsingState = ParsingState.Idle;
let statusText = '';
let statusSpanCounter = 0;
const nameParts: string[] = [];
const summaryParts: string[] = [];
const scriptContentParts: string[] = [];
const genreArray: string[] = [];
let chapterJson: ChapterEntry[] = [];
let volumeJson: VolumeEntry[] = [];
const parser = new Parser({
onopentag(name, attribs) {
switch (name) {
case 'h1':
state = ParsingState.InTitle;
break;
case 'a':
if (attribs['href']?.startsWith('/profile/') && !novel.author) {
state = ParsingState.InAuthor;
} else if (state === ParsingState.InTags) {
state = ParsingState.InTagLink;
}
break;
case 'div':
if (attribs['class'] === 'description') {
state = ParsingState.InDescription;
}
break;
case 'hr':
if (state === ParsingState.InDescription) {
summaryParts.push('\n\n---\n\n');
}
break;
case 'br':
if (state === ParsingState.InDescription) {
summaryParts.push('\n\n');
}
break;
case 'span':
if (attribs['class']?.includes('tags')) {
state = ParsingState.InTags;
} else if (attribs['class']?.includes('label-sm')) {
statusSpanCounter++;
if (statusSpanCounter === 2) {
state = ParsingState.InStatusSpan;
statusText = '';
}
}
break;
case 'img':
if (attribs['class']?.includes('thumbnail')) {
novel.cover = attribs['src'];
if (novel.cover && !isUrlAbsolute(novel.cover)) {
novel.cover = baseUrl + novel.cover.slice(1);
}
}
break;
case 'script':
state = ParsingState.InScript;
break;
}
},
ontext(text) {
const trimmedText = text.trim();
if (!trimmedText && state !== ParsingState.InScript) return;
switch (state) {
case ParsingState.InTitle:
nameParts.push(text);
break;
case ParsingState.InAuthor:
novel.author = trimmedText;
break;
case ParsingState.InDescription:
summaryParts.push(text);
break;
case ParsingState.InStatusSpan:
statusText = trimmedText;
break;
case ParsingState.InTagLink:
genreArray.push(trimmedText);
break;
case ParsingState.InScript:
scriptContentParts.push(text);
break;
}
},
onclosetag(name) {
switch (name) {
case 'h1':
if (state === ParsingState.InTitle) {
novel.name = nameParts.join('').trim();
state = ParsingState.Idle;
}
break;
case 'a':
if (state === ParsingState.InTagLink) {
state = ParsingState.InTags;
} else if (state === ParsingState.InAuthor) {
state = ParsingState.Idle;
}
break;
case 'p':
if (state === ParsingState.InDescription) {
summaryParts.push('\n\n');
}
break;
case 'div':
if (state === ParsingState.InDescription) {
novel.summary = summaryParts
.join('')
.replace(/&nbsp;/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
summaryParts.length = 0;
state = ParsingState.Idle;
}
break;
case 'span':
if (state === ParsingState.InTags) {
novel.genres = genreArray.join(', ');
state = ParsingState.Idle;
} else if (state === ParsingState.InStatusSpan) {
state = ParsingState.Idle;
}
break;
case 'script':
if (state === ParsingState.InScript) {
state = ParsingState.Idle;
const scriptContent = scriptContentParts.join('');
const chapterMatch = scriptContent.match(
/window\.chapters\s*=\s*(\[.*?\]);/,
);
const volumeMatch = scriptContent.match(
/window\.volumes\s*=\s*(\[.*?\]);/,
);
if (chapterMatch?.[1]) {
chapterJson = JSON.parse(chapterMatch[1]);
}
if (volumeMatch?.[1]) {
volumeJson = JSON.parse(volumeMatch[1]);
}
}
break;
}
},
onend() {
switch (statusText) {
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;
}
novel.chapters = chapterJson.map((chapter: ChapterEntry) => {
const matchingVolume = volumeJson.find(
(volume: VolumeEntry) => volume.id === chapter.volumeId,
);
return {
name: chapter.title,
path: (() => {
const parts = chapter.url.split('/');
return `${parts[1]}/${parts[2]}/${parts[4]}/${parts[5]}`;
})(),
releaseTime: chapter.date,
chapterNumber: chapter?.order,
page: matchingVolume?.title,
};
});
},
});
parser.write(html);
parser.end();
return novel as Plugin.NovelItem;
}
async parseChapter(chapterPath: string): Promise<string> {
const result = await fetchApi(this.site + chapterPath);
const html = await result.text();
let state = ParsingState.Idle;
let stateDepth = 0;
let depth = 0;
const chapterHtmlParts: string[] = [];
const notesHtmlParts: string[] = [];
const beforeNotesParts: string[] = [];
const afterNotesParts: string[] = [];
let isBeforeChapter = true;
const match = html.match(/<style>\n\s+.(.+?){[^{]+?display: none;/);
const hiddenClass = match?.[1]?.trim();
let stateBeforeHidden: {
state: ParsingState;
depth: number;
} | null = null;
type EscapeChar = '&' | '<' | '>' | '"' | "'";
const escapeRegex = /[&<>"']/g;
const escapeMap: Record<EscapeChar, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
const escapeHtml = (text: string): string =>
escapeRegex.test(text)
? ((escapeRegex.lastIndex = 0),
text.replace(escapeRegex, char => escapeMap[char as EscapeChar]))
: text;
const parser = new Parser({
onopentag(name, attribs) {
depth++;
const classes = attribs['class'] || '';
if (
state !== ParsingState.InHidden &&
hiddenClass &&
classes.includes(hiddenClass)
) {
stateBeforeHidden = { state: state, depth: stateDepth };
state = ParsingState.InHidden;
stateDepth = depth;
return;
}
switch (state) {
case ParsingState.Idle:
if (classes.includes('chapter-content')) {
state = ParsingState.InChapter;
stateDepth = depth;
isBeforeChapter = false;
} else if (classes.includes('author-note-portlet')) {
state = ParsingState.InNote;
stateDepth = depth;
}
break;
case ParsingState.InHidden:
return;
}
if (state === ParsingState.InChapter || state === ParsingState.InNote) {
let tag = `<${name}`;
for (const attr in attribs) {
const value = attribs[attr].replace(/"/g, '&quot;');
tag += ` ${attr}="${value}"`;
}
tag += '>';
if (state === ParsingState.InChapter) {
chapterHtmlParts.push(tag);
} else {
notesHtmlParts.push(tag);
}
}
},
ontext(text) {
switch (state) {
case ParsingState.InChapter:
chapterHtmlParts.push(escapeHtml(text));
break;
case ParsingState.InNote:
notesHtmlParts.push(escapeHtml(text));
break;
}
},
onclosetag(name) {
if (depth === stateDepth) {
switch (state) {
case ParsingState.InHidden:
if (!stateBeforeHidden) {
state = ParsingState.Idle; // Attempt recovery
stateDepth = 0;
} else {
state = stateBeforeHidden.state;
stateDepth = stateBeforeHidden.depth;
stateBeforeHidden = null;
}
depth--;
return;
case ParsingState.InChapter:
chapterHtmlParts.push(`</div>`);
state = ParsingState.Idle;
stateDepth = 0;
depth--;
return;
case ParsingState.InNote:
const noteClass = `author-note-${isBeforeChapter ? 'before' : 'after'}`;
const notesHtml = notesHtmlParts.join('').trim();
const fullNote = `<div class="${noteClass}">${notesHtml}</div>`;
if (isBeforeChapter) {
beforeNotesParts.push(fullNote);
} else {
afterNotesParts.push(fullNote);
}
notesHtmlParts.length = 0;
state = ParsingState.Idle;
stateDepth = 0;
depth--;
return;
}
} else if (
state === ParsingState.InChapter ||
state === ParsingState.InNote
) {
if (!parser['isVoidElement'](name)) {
const closingTag = `</${name}>`;
if (state === ParsingState.InChapter) {
chapterHtmlParts.push(closingTag);
} else {
notesHtmlParts.push(closingTag);
}
}
}
depth--;
},
});
parser.write(html);
parser.end();
return [
beforeNotesParts.length > 0 ? beforeNotesParts.join('') : null,
chapterHtmlParts.length > 0 ? chapterHtmlParts.join('').trim() : null,
afterNotesParts.length > 0 ? afterNotesParts.join('') : null,
]
.filter(Boolean)
.join('\n<hr class="notes-separator">\n');
}
async searchNovels(
searchTerm: string,
page: number,
): Promise<Plugin.NovelItem[]> {
const params = new URLSearchParams({
page: page.toString(),
title: searchTerm,
globalFilters: 'true',
});
const searchUrl = `${this.site}fictions/search?${params.toString()}`;
const body = await fetchApi(searchUrl).then(r => r.text());
return this.parseNovels(body);
}
filters = {
'keyword': {
'type': FilterTypes.TextInput,
'label': 'Keyword (title or description)',
'value': '',
},
'author': {
'type': FilterTypes.TextInput,
'label': 'Author',
'value': '',
},
'genres': {
'type': FilterTypes.ExcludableCheckboxGroup,
'label': 'Genres',
'value': {
'include': [],
'exclude': [],
},
'options': [
{
'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',
},
],
},
'tags': {
'type': FilterTypes.ExcludableCheckboxGroup,
'label': 'Tags',
'value': {
'include': [],
'exclude': [],
},
'options': [
{
'label': 'Anti-Hero Lead',
'value': 'anti-hero_lead',
},
{
'label': 'Artificial Intelligence',
'value': 'artificial_intelligence',
},
{
'label': 'Attractive Lead',
'value': 'attractive_lead',
},
{
'label': 'Cyberpunk',
'value': 'cyberpunk',
},
{
'label': 'Dungeon',
'value': 'dungeon',
},
{
'label': 'Dystopia',
'value': 'dystopia',
},
{
'label': 'Female Lead',
'value': 'female_lead',
},
{
'label': 'First Contact',
'value': 'first_contact',
},
{
'label': 'GameLit',
'value': 'gamelit',
},
{
'label': 'Gender Bender',
'value': 'gender_bender',
},
{
'label': 'Genetically Engineered',
'value': 'genetically_engineered ',
},
{
'label': 'Grimdark',
'value': 'grimdark',
},
{
'label': 'Hard Sci-fi',
'value': 'hard_sci-fi',
},
{
'label': 'Harem',
'value': 'harem',
},
{
'label': 'High Fantasy',
'value': 'high_fantasy',
},
{
'label': 'LitRPG',
'value': 'litrpg',
},
{
'label': 'Low Fantasy',
'value': 'low_fantasy',
},
{
'label': 'Magic',
'value': 'magic',
},
{
'label': 'Male Lead',
'value': 'male_lead',
},
{
'label': 'Martial Arts',
'value': 'martial_arts',
},
{
'label': 'Multiple Lead Characters',
'value': 'multiple_lead',
},
{
'label': 'Mythos',
'value': 'mythos',
},
{
'label': 'Non-Human Lead',
'value': 'non-human_lead',
},
{
'label': 'Portal Fantasy / Isekai',
'value': 'summoned_hero',
},
{
'label': 'Post Apocalyptic',
'value': 'post_apocalyptic',
},
{
'label': 'Progression',
'value': 'progression',
},
{
'label': 'Reader Interactive',
'value': 'reader_interactive',
},
{
'label': 'Reincarnation',
'value': 'reincarnation',
},
{
'label': 'Ruling Class',
'value': 'ruling_class',
},
{
'label': 'School Life',
'value': 'school_life',
},
{
'label': 'Secret Identity',
'value': 'secret_identity',
},
{
'label': 'Slice of Life',
'value': 'slice_of_life',
},
{
'label': 'Soft Sci-fi',
'value': 'soft_sci-fi',
},
{
'label': 'Space Opera',
'value': 'space_opera',
},
{
'label': 'Sports',
'value': 'sports',
},
{
'label': 'Steampunk',
'value': 'steampunk',
},
{
'label': 'Strategy',
'value': 'strategy',
},
{
'label': 'Strong Lead',
'value': 'strong_lead',
},
{
'label': 'Super Heroes',
'value': 'super_heroes',
},
{
'label': 'Supernatural',
'value': 'supernatural',
},
{
'label': 'Technologically Engineered',
'value': 'technologically_engineered',
},
{
'label': 'Time Loop',
'value': 'loop',
},
{
'label': 'Time Travel',
'value': 'time_travel',
},
{
'label': 'Urban Fantasy',
'value': 'urban_fantasy',
},
{
'label': 'Villainous Lead',
'value': 'villainous_lead',
},
{
'label': 'Virtual Reality',
'value': 'virtual_reality',
},
{
'label': 'War and Military',
'value': 'war_and_military',
},
{
'label': 'Wuxia',
'value': 'wuxia',
},
{
'label': 'Xianxia',
'value': 'xianxia',
},
],
},
'content_warnings': {
'type': FilterTypes.ExcludableCheckboxGroup,
'label': 'Content Warnings',
'value': {
'include': [],
'exclude': [],
},
'options': [
{
'label': 'Profanity',
'value': 'profanity',
},
{
'label': 'Sexual Content',
'value': 'sexuality',
},
{
'label': 'Graphic Violence',
'value': 'graphic_violence',
},
{
'label': 'Sensitive Content',
'value': 'sensitive',
},
{
'label': 'AI-Assisted Content',
'value': 'ai_assisted',
},
{
'label': 'AI-Generated Content',
'value': 'ai_generated',
},
],
},
'minPages': {
'type': FilterTypes.TextInput,
'label': 'Min Pages',
'value': '0',
},
'maxPages': {
'type': FilterTypes.TextInput,
'label': 'Max Pages',
'value': '20000',
},
'minRating': {
'type': FilterTypes.TextInput,
'label': 'Min Rating (0.0 - 5.0)',
'value': '0.0',
},
'maxRating': {
'type': FilterTypes.TextInput,
'label': 'Max Rating (0.0 - 5.0)',
'value': '5.0',
},
'status': {
'type': FilterTypes.Picker,
'label': 'Status',
'value': 'ALL',
'options': [
{
'label': 'All',
'value': 'ALL',
},
{
'label': 'Completed',
'value': 'COMPLETED',
},
{
'label': 'Dropped',
'value': 'DROPPED',
},
{
'label': 'Ongoing',
'value': 'ONGOING',
},
{
'label': 'Hiatus',
'value': 'HIATUS',
},
{
'label': 'Stub',
'value': 'STUB',
},
],
},
'orderBy': {
'type': FilterTypes.Picker,
'label': 'Order by',
'value': 'relevance',
'options': [
{
'label': 'Relevance',
'value': 'relevance',
},
{
'label': 'Popularity',
'value': 'popularity',
},
{
'label': 'Average Rating',
'value': 'rating',
},
{
'label': 'Last Update',
'value': 'last_update',
},
{
'label': 'Release Date',
'value': 'release_date',
},
{
'label': 'Followers',
'value': 'followers',
},
{
'label': 'Number of Pages',
'value': 'length',
},
{
'label': 'Views',
'value': 'views',
},
{
'label': 'Title',
'value': 'title',
},
{
'label': 'Author',
'value': 'author',
},
],
},
'dir': {
'type': FilterTypes.Picker,
'label': 'Direction',
'value': 'desc',
'options': [
{
'label': 'Ascending',
'value': 'asc',
},
{
'label': 'Descending',
'value': 'desc',
},
],
},
'type': {
'type': FilterTypes.Picker,
'label': 'Type',
'value': 'ALL',
'options': [
{
'label': 'All',
'value': 'ALL',
},
{
'label': 'Fan Fiction',
'value': 'fanfiction',
},
{
'label': 'Original',
'value': 'original',
},
],
},
} satisfies Filters;
}
export default new RoyalRoad();
type ChapterEntry = {
id: number;
volumeId: number;
title: string;
date: string;
order: number;
url: string;
};
type VolumeEntry = {
id: number;
title: string;
cover: string;
order: number;
};
enum ParsingState {
Idle,
InTitle,
InAuthor,
InDescription,
InTags,
InTagLink,
InStatusSpan,
InScript,
InNote,
InChapter,
InHidden,
Novel,
}
+303
View File
@@ -0,0 +1,303 @@
import { CheerioAPI, load as parseHTML } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { FilterTypes, Filters } from '@libs/filterInputs';
import { Plugin } from '@/types/plugin';
import dayjs from 'dayjs';
class ScribbleHubPlugin implements Plugin.PluginBase {
id = 'scribblehub';
name = 'Scribble Hub';
icon = 'src/en/scribblehub/icon.png';
site = 'https://www.scribblehub.com/';
version = '1.0.2';
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 url = `${this.site}`;
if (showLatestNovels) {
url += `latest-series/?pg=${page}`;
} else if (filters) {
const params = new URLSearchParams();
if (filters.genres.value.include?.length) {
params.append('gi', filters.genres.value.include.join(','));
}
if (
filters.genres.value.include?.length ||
filters.genres.value.exclude?.length
) {
params.append('mgi', filters.genre_operator.value);
}
if (filters.genres.value.exclude?.length) {
params.append('ge', filters.genres.value.exclude.join(','));
}
if (filters.content_warning.value.include?.length) {
params.append('cti', filters.content_warning.value.include.join(','));
}
if (
filters.content_warning.value.include?.length ||
filters.content_warning.value.exclude?.length
) {
params.append('mct', filters.content_warning_operator.value);
}
if (filters.content_warning.value.exclude?.length) {
params.append('cte', filters.content_warning.value.exclude.join(','));
}
params.append('cp', filters.storyStatus.value);
params.append('sort', filters.sort.value);
params.append('order', filters.order.value);
params.append('pg', page.toString());
url += `series-finder/?sf=1&${params.toString()}`;
} else {
url += `series-finder/?sf=1&sort=ratings&order=desc&pg=${page}`;
}
const body = await fetchApi(url).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): Promise<Plugin.NovelItem[]> {
const url = `${this.site}?s=${encodeURIComponent(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',
value: 'all',
options: [
{ label: 'All', value: 'all' },
{ label: 'Completed', value: 'completed' },
{ label: 'Ongoing', value: 'ongoing' },
{ label: 'Hiatus', value: 'hiatus' },
],
type: FilterTypes.Picker,
},
genre_operator: {
value: 'and',
label: 'Genres (And/Or)',
options: [
{ label: 'And', value: 'and' },
{ label: 'Or', value: 'or' },
],
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,
},
content_warning_operator: {
value: 'and',
label: 'Mature Content (And/Or)',
options: [
{ label: 'And', value: 'and' },
{ label: 'Or', value: 'or' },
],
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,
},
} satisfies Filters;
}
export default new ScribbleHubPlugin();
+176
View File
@@ -0,0 +1,176 @@
import { Plugin } from '@/types/plugin';
import { FilterTypes, Filters } from '@libs/filterInputs';
import { defaultCover } from '@libs/defaultCover';
import { fetchApi } from '@libs/fetch';
import { NovelStatus } from '@libs/novelStatus';
import { load as parseHTML } from 'cheerio';
import dayjs from 'dayjs';
class VyNovel implements Plugin.PluginBase {
id = 'vynovel';
name = 'VyNovel';
site = 'https://vynovel.com';
version = '1.0.0';
icon = 'src/en/vynovel/icon.png';
async fetchNovels(
page: number,
{
filters,
showLatestNovels,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
searchTerm?: string,
): Promise<Plugin.NovelItem[]> {
const data = new URLSearchParams({
sort: showLatestNovels ? 'updated_at' : filters?.sort?.value || 'viewed',
page,
});
if (searchTerm) data.append('q', searchTerm);
const url = this.site + '/search?' + data.toString();
const body = await fetchApi(url).then(res => res.text());
const loadedCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
loadedCheerio('div[class="comic-item"] > a').each((index, element) => {
const name = loadedCheerio(element)
.find('div[class="comic-title"]')
.text()
?.trim();
const cover =
loadedCheerio(element)
.find('div[class="comic-image lozad "]')
.attr('data-background-image') || defaultCover;
const url = loadedCheerio(element).attr('href');
if (!name || !url) return;
novels.push({ name, cover, path: url.replace('/novel/', '') });
});
return novels;
}
popularNovels = this.fetchNovels;
async searchNovels(
searchTerm: string,
page: number,
): Promise<Plugin.NovelItem[]> {
const defaultOptions: any = {
filters: undefined,
showLatestNovels: false,
};
return this.fetchNovels(page, defaultOptions, searchTerm);
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const body = await fetchApi(this.resolveUrl(novelPath, true)).then(res =>
res.text(),
);
const loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel = {
path: novelPath,
name: loadedCheerio('h1[class="title"]').text().trim(),
cover:
loadedCheerio('div[class="img-manga"] > img').attr('src') ||
defaultCover,
summary: loadedCheerio('div[class="summary"] > p[class="content"]')
.text()
.trim(),
author: loadedCheerio('div[class="col-md-7"] > p:nth-child(5) > a')
.text()
.trim(),
status:
loadedCheerio('span[class="text-ongoing"]').text() === 'Ongoing'
? NovelStatus.Ongoing
: NovelStatus.Completed,
};
const chapters: Plugin.ChapterItem[] = [];
const totalChapters = loadedCheerio('div[class="list-group"] > a').length;
loadedCheerio('div[class="list-group"] > a').each(
(chapterIndex, element) => {
const name = loadedCheerio(element).find('span').text().trim();
const id = loadedCheerio(element).attr('id')?.replace(/\D/g, '');
if (!name || !id) return;
const releaseDate = loadedCheerio(element).find('p').text();
chapters.push({
name,
path: novelPath + '/' + id,
releaseTime: this.parseDate(releaseDate?.trim()),
chapterNumber: totalChapters - chapterIndex,
});
},
);
novel.chapters = chapters.reverse();
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const body = await fetchApi(this.resolveUrl(chapterPath)).then(res =>
res.text(),
);
const loadedCheerio = parseHTML(body);
const chapterText = loadedCheerio('.content').html();
return chapterText || '';
}
parseDate = (date = '') => {
if (!date) return null;
if (date.includes('ago')) {
const [value, type] = date.split(' ');
if (!value || !type) return null;
switch (type.toLowerCase()) {
case 'minutes': {
const minutes = parseInt(value, 10);
date = Date.now() - minutes * 60 * 1000;
break;
}
case 'hour':
case 'hours': {
const hours = parseInt(value, 10);
date = Date.now() - hours * 60 * 60 * 1000;
break;
}
case 'day':
case 'days': {
const days = parseInt(value, 10);
date = Date.now() - days * 24 * 60 * 60 * 1000;
break;
}
default:
console.log(date);
date = undefined;
}
return dayjs(date).format('LLL');
}
return date;
};
resolveUrl = (path: string, isNovel?: boolean) =>
this.site + (isNovel ? '/novel/' : '/read/') + path;
filters = {
sort: {
label: 'Sort By:',
value: 'viewed',
options: [
{ label: 'Viewed', value: 'viewed' },
{ label: 'Scored', value: 'scored' },
{ label: 'Newest', value: 'created_at' },
{ label: 'Latest Update', value: 'updated_at' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
}
export default new VyNovel();
+340
View File
@@ -0,0 +1,340 @@
import { CheerioAPI, load as parseHTML } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Filters, FilterTypes } from '@libs/filterInputs';
import { Plugin } from '@/types/plugin';
import { storage } from '@/lib/storage';
class Webnovel implements Plugin.PluginBase {
id = 'webnovel';
name = 'Webnovel';
version = '1.0.3';
icon = 'src/en/webnovel/icon.png';
site = 'https://www.webnovel.com';
headers = {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
};
imageRequestInit?: Plugin.ImageRequestInit | undefined = {
headers: {
'referrer': this.site,
},
};
hideLocked = storage.get('hideLocked');
pluginSettings = {
hideLocked: {
value: '',
label: 'Hide locked chapters',
type: 'Switch',
},
};
async parseNovels(
loadedCheerio: CheerioAPI,
category_bool: boolean,
search_bool: boolean,
): Promise<Plugin.NovelItem[]> {
const selector = category_bool
? '.j_category_wrapper'
: search_bool
? '.j_list_container'
: '';
const attribute = category_bool
? 'data-original'
: search_bool
? 'src'
: '';
return loadedCheerio(`${selector} li`)
.map((i_, ele) => {
const novelName =
loadedCheerio(ele).find('.g_thumb').attr('title') || 'No Title Found';
const novelCover = loadedCheerio(ele)
.find('.g_thumb > img')
.attr(attribute);
const novelPath = loadedCheerio(ele).find('.g_thumb').attr('href');
if (!novelPath) return null;
return {
name: novelName,
cover: 'https:' + novelCover,
path: novelPath,
};
})
.get()
.filter(novel => novel !== null);
}
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
if (filters?.fanfic_search.value) {
return this.searchNovelsInternal(
filters.fanfic_search.value,
pageNo,
'fanfic',
);
}
let url = this.site + '/stories/';
const params = new URLSearchParams();
if (showLatestNovels) {
url += `novel?orderBy=5&pageIndex=${pageNo}`;
} else if (filters) {
if (filters.genres_gender.value === '1') {
if (filters.genres_male.value !== '1') {
url += filters.genres_male.value;
} else {
url += 'novel';
params.append('gender', '1');
}
} else if (filters.genres_gender.value === '2') {
if (filters.genres_female.value !== '2') {
url += filters.genres_female.value;
} else {
url += 'novel';
params.append('gender', '2');
}
}
if (filters.type.value !== '3') {
params.append('sourceType', filters.type.value);
} else {
params.append('translateMode', '3');
params.append('sourceType', '1');
}
params.append('bookStatus', filters.status.value);
params.append('orderBy', filters.sort.value);
params.append('pageIndex', pageNo.toString());
url += '?' + params.toString();
} else {
url += `novel?orderBy=1&pageIndex=${pageNo}`;
}
const result = await fetchApi(url, {
headers: this.headers,
});
const body = await result.text();
const loadedCheerio = parseHTML(body);
return this.parseNovels(loadedCheerio, true, false);
}
async parseChapters(novelPath: string): Promise<Plugin.ChapterItem[]> {
const url = this.site + novelPath + '/catalog';
const result = await fetchApi(url, {
headers: this.headers,
});
const body = await result.text();
const loadedCheerio = parseHTML(body);
const chapters: Plugin.ChapterItem[] = [];
loadedCheerio('.volume-item').each((i_v_, ele_v) => {
const originalVolumeName = loadedCheerio(ele_v).first().text().trim();
const volumeNameMatch = originalVolumeName.match(/Volume\s(\d+)/);
const volumeName = volumeNameMatch
? `Volume ${volumeNameMatch[1]}`
: 'Unknown Volume';
loadedCheerio(ele_v)
.find('li')
.each((i_c_, ele_c) => {
const chapterName =
`${volumeName}: ` +
(loadedCheerio(ele_c).find('a').attr('title')?.trim() ||
'No Title Found');
const chapterPath = loadedCheerio(ele_c).find('a').attr('href');
const locked = loadedCheerio(ele_c).find('svg').length;
if (chapterPath && !(locked && this.hideLocked)) {
chapters.push({
name: locked ? `${chapterName} 🔒` : chapterName,
path: chapterPath,
});
}
});
});
return chapters;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const url = this.site + novelPath;
const result = await fetchApi(url, {
headers: this.headers,
});
const body = await result.text();
const loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel = {
path: novelPath,
name: loadedCheerio('.g_thumb > img').attr('alt') || 'No Title Found',
cover: 'https:' + loadedCheerio('.g_thumb > img').attr('src'),
genres: loadedCheerio('.det-hd-detail > .det-hd-tag').attr('title') || '',
summary:
loadedCheerio('.j_synopsis > p')
.find('br')
.replaceWith('\n')
.end()
.text()
.trim() || 'No Summary Found',
author:
loadedCheerio('.det-info .c_s')
.filter((i_, ele) => {
return loadedCheerio(ele).text().trim() === 'Author:';
})
.next()
.text()
.trim() || 'No Author Found',
status:
loadedCheerio('.det-hd-detail svg')
.filter((i_, ele) => {
return loadedCheerio(ele).attr('title') === 'Status';
})
.next()
.text()
.trim() || 'Unknown Status',
chapters: await this.parseChapters(novelPath),
};
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const url = this.site + chapterPath;
const result = await fetchApi(url, {
headers: this.headers,
});
const body = await result.text();
const loadedCheerio = parseHTML(body);
const bloatElements = ['.para-comment'];
bloatElements.forEach(tag => loadedCheerio(tag).remove());
return (
loadedCheerio('.cha-tit').html()! + loadedCheerio('.cha-words').html()!
);
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
return this.searchNovelsInternal(searchTerm, pageNo);
}
async searchNovelsInternal(
searchTerm: string,
pageNo: number,
type?: string,
): Promise<Plugin.NovelItem[]> {
searchTerm = searchTerm.replace(/\s+/g, '+');
const url = `${this.site}/search?keywords=${encodeURIComponent(searchTerm)}&pageIndex=${pageNo}${type ? `&type=${type}` : ''}`;
const result = await fetchApi(url, {
headers: this.headers,
});
const body = await result.text();
const loadedCheerio = parseHTML(body);
return this.parseNovels(loadedCheerio, false, true);
}
filters = {
sort: {
label: 'Sort Results By',
value: '1',
options: [
{ label: 'Popular', value: '1' },
{ label: 'Recommended', value: '2' },
{ label: 'Most Collections', value: '3' },
{ label: 'Rating', value: '4' },
{ label: 'Time Updated', value: '5' },
],
type: FilterTypes.Picker,
},
status: {
label: 'Content Status',
value: '0',
options: [
{ label: 'All', value: '0' },
{ label: 'Completed', value: '2' },
{ label: 'Ongoing', value: '1' },
],
type: FilterTypes.Picker,
},
genres_gender: {
label: 'Genres (Male/Female)',
value: '1',
options: [
{ label: 'Male', value: '1' },
{ label: 'Female', value: '2' },
],
type: FilterTypes.Picker,
},
genres_male: {
label: 'Male Genres',
value: '1',
options: [
{ label: 'All', value: '1' },
{ label: 'Action', value: 'novel-action-male' },
{ label: 'Animation, Comics, Games', value: 'novel-acg-male' },
{ label: 'Eastern', value: 'novel-eastern-male' },
{ label: 'Fantasy', value: 'novel-fantasy-male' },
{ label: 'Games', value: 'novel-games-male' },
{ label: 'History', value: 'novel-history-male' },
{ label: 'Horror', value: 'novel-horror-male' },
{ label: 'Realistic', value: 'novel-realistic-male' },
{ label: 'Sci-fi', value: 'novel-scifi-male' },
{ label: 'Sports', value: 'novel-sports-male' },
{ label: 'Urban', value: 'novel-urban-male' },
{ label: 'War', value: 'novel-war-male' },
],
type: FilterTypes.Picker,
},
genres_female: {
label: 'Female Genres',
value: '2',
options: [
{ label: 'All', value: '2' },
{ label: 'Fantasy', value: 'novel-fantasy-female' },
{ label: 'General', value: 'novel-general-female' },
{ label: 'History', value: 'novel-history-female' },
{ label: 'LGBT+', value: 'novel-lgbt-female' },
{ label: 'Sci-fi', value: 'novel-scifi-female' },
{ label: 'Teen', value: 'novel-teen-female' },
{ label: 'Urban', value: 'novel-urban-female' },
],
type: FilterTypes.Picker,
},
type: {
label: 'Content Type',
value: '0',
options: [
{ label: 'All', value: '0' },
{ label: 'Translate', value: '1' },
{ label: 'Original', value: '2' },
{ label: 'MTL (Machine Translation)', value: '3' },
],
type: FilterTypes.Picker,
},
fanfic_search: {
label: 'Search fanfics (Overrides other filters)',
value: '',
type: FilterTypes.TextInput,
},
} satisfies Filters;
}
export default new Webnovel();
+298
View File
@@ -0,0 +1,298 @@
import { Plugin } from '@/types/plugin';
import { fetchApi } 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 fetchApi(this.site + '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 fetchApi(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;
}
type NovelJson = {
props: Props;
page: string;
};
type Props = {
pageProps: PageProps;
__N_SSP: boolean;
};
type PageProps = {
serie: Serie;
server_time: Date;
};
type 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;
};
type Chapter = {
serie_id: number;
id: number;
order: number;
slug: string;
title: string;
name: string;
created_at: string;
updated_at: string;
};
type ChapterData = {
data: ChapterContent;
};
type ChapterContent = {
title: string;
body: string;
};
type 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[];
};
type Data = {
title: string;
author: string;
description: string;
image: string;
};
type JsonNovel = {
success: boolean;
data: Datum[];
};
type Datum = {
serie: Serie;
chapters: Chapter[];
updated_at: Date;
raw_id: number;
slug: string;
data: Data;
};
export default new WTRLAB();
+468
View File
@@ -0,0 +1,468 @@
import { load as parseHTML } from 'cheerio';
import { fetchApi, fetchProto } from '@/lib/fetch';
import { Plugin } from '@/types/plugin';
import { Filters } from '@libs/filterInputs';
import { NovelStatus } from '@libs/novelStatus';
type StringValue = {
value: string;
};
type BoolValue = {
value: boolean;
};
type DecimalValue = {
units: bigint;
nanos: number;
};
type Timestamp = {
seconds: number;
nanos: number;
};
type NovelEntry = {
name: string;
coverUrl: string;
slug: string;
};
type RelatedChapterUserInfo = {
isChapterUnlocked?: BoolValue;
};
type ChapterItem = {
entityId: number;
name: string;
slug: string;
number?: DecimalValue;
content?: StringValue;
relatedUserInfo?: RelatedChapterUserInfo;
offset: number;
publishedAt?: Timestamp;
};
type GetChapterResponse = {
item?: ChapterItem;
};
type ChapterGroupCounts = {
total: number;
advance: number;
normal: number;
};
type ChapterGroupItem = {
id: number;
title: string;
order: number;
fromChapterNumber?: DecimalValue;
toChapterNumber?: DecimalValue;
chapterList: ChapterItem[];
counts?: ChapterGroupCounts;
};
type GetChapterListResponse = {
items: ChapterGroupItem[];
};
enum NovelItem_Status {
Finished = 0,
Active = 1,
Hiatus = 2,
All = -1,
}
type NovelKarmaInfo = {
isActive: boolean;
isFree: boolean;
maxFreeChapter?: DecimalValue;
canUnlockWithVip: boolean;
};
type 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[];
};
type GetNovelResponse = {
item?: NovelItem;
};
class WuxiaWorld implements Plugin.PluginBase {
id = 'wuxiaworld';
name = 'Wuxia World';
icon = 'src/en/wuxiaworld/icon.png';
site = 'https://www.wuxiaworld.com/';
apiSite = 'https://api2.wuxiaworld.com/wuxiaworld.api.v2.';
filters?: Filters | undefined;
version = '0.5.1';
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(): 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] },
},
this.apiSite + '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 },
},
this.apiSite + '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],
},
},
},
},
this.apiSite + '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): Promise<Plugin.NovelItem[]> {
const url =
this.site + 'api/novels/search?query=' + encodeURIComponent(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();