c7f6a9b5ee
* ippotranslations, cpunovel * fix * test * [ranobelib] heading -> h2 (fixes LNReader/lnreader-plugins#960) * icon
418 lines
13 KiB
TypeScript
418 lines
13 KiB
TypeScript
import { load } from 'cheerio';
|
||
import { Parser } from 'htmlparser2';
|
||
import { fetchApi, fetchFile } from '@libs/fetch';
|
||
import { Plugin } from '@typings/plugin';
|
||
import { NovelStatus } from '@libs/novelStatus';
|
||
import { defaultCover } from '@libs/defaultCover';
|
||
import { Filters } from '@libs/filterInputs';
|
||
|
||
interface LightNovelWPOptions {
|
||
reverseChapters?: boolean;
|
||
lang?: string;
|
||
versionIncrements?: number;
|
||
seriesPath?: string;
|
||
customJs?: string;
|
||
}
|
||
|
||
export interface LightNovelWPMetadata {
|
||
id: string;
|
||
sourceSite: string;
|
||
sourceName: string;
|
||
options?: LightNovelWPOptions;
|
||
filters?: any;
|
||
}
|
||
|
||
class LightNovelWPPlugin implements Plugin.PluginBase {
|
||
id: string;
|
||
name: string;
|
||
icon: string;
|
||
site: string;
|
||
version: string;
|
||
options?: LightNovelWPOptions;
|
||
filters?: Filters;
|
||
|
||
constructor(metadata: LightNovelWPMetadata) {
|
||
this.id = metadata.id;
|
||
this.name = metadata.sourceName;
|
||
this.icon = `multisrc/lightnovelwp/${metadata.id.toLowerCase()}/icon.png`;
|
||
this.site = metadata.sourceSite;
|
||
const versionIncrements = metadata.options?.versionIncrements || 0;
|
||
this.version = `1.1.${versionIncrements}`;
|
||
this.options = metadata.options ?? ({} as LightNovelWPOptions);
|
||
this.filters = metadata.filters satisfies Filters;
|
||
}
|
||
|
||
getHostname(url: string): string {
|
||
url = url.split('/')[2];
|
||
let url_parts = url.split('.');
|
||
url_parts.pop(); // remove TLD
|
||
return url_parts.join('.');
|
||
}
|
||
|
||
async safeFecth(url: string, search: boolean): Promise<string> {
|
||
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 data = await r.text();
|
||
const title = data.match(/<title>(.*?)<\/title>/)?.[1]?.trim();
|
||
|
||
if (
|
||
this.getHostname(url) != this.getHostname(r.url) ||
|
||
(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;
|
||
}
|
||
|
||
parseNovels(html: string): Plugin.NovelItem[] {
|
||
const novels: Plugin.NovelItem[] = [];
|
||
|
||
const articles = html.match(/<article([\s\S]*?)<\/article>/g) || [];
|
||
articles.forEach(article => {
|
||
const novelName = article.match(/<a.*title="(.*?)"/)?.[1];
|
||
const novelUrl = article.match(/<a href="(.*?)"/)?.[1];
|
||
|
||
if (novelName && novelUrl) {
|
||
const novelCover =
|
||
article.match(/<img.*src="(.*?)"(?:\sdata-src="(.*?)")?.*\/>/) || [];
|
||
|
||
novels.push({
|
||
name: novelName,
|
||
cover: novelCover[2] || novelCover[1] || defaultCover,
|
||
path: novelUrl.replace(this.site, ''),
|
||
});
|
||
}
|
||
});
|
||
|
||
return novels;
|
||
}
|
||
|
||
async popularNovels(
|
||
pageNo: number,
|
||
{
|
||
filters,
|
||
showLatestNovels,
|
||
}: Plugin.PopularNovelsOptions<typeof this.filters>,
|
||
): Promise<Plugin.NovelItem[]> {
|
||
const seriesPath = this.options?.seriesPath ?? 'series/';
|
||
let url = this.site + seriesPath + '?page=' + pageNo;
|
||
if (!filters) filters = this.filters || {};
|
||
if (showLatestNovels) url += '&order=latest';
|
||
for (const key in filters) {
|
||
if (typeof filters[key].value === 'object')
|
||
for (const value of filters[key].value as string[])
|
||
url += `&${key}=${value}`;
|
||
else if (filters[key].value) url += `&${key}=${filters[key].value}`;
|
||
}
|
||
const html = await this.safeFecth(url, false);
|
||
return this.parseNovels(html);
|
||
}
|
||
|
||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||
const baseURL = this.site;
|
||
const html = await this.safeFecth(baseURL + novelPath, false);
|
||
|
||
const novel: Plugin.SourceNovel = {
|
||
path: novelPath,
|
||
name: '',
|
||
genres: '',
|
||
summary: '',
|
||
author: '',
|
||
artist: '',
|
||
status: '',
|
||
chapters: [] as Plugin.ChapterItem[],
|
||
};
|
||
let isParsingGenres = false;
|
||
let isReadingGenre = false;
|
||
let isReadingSummary = false;
|
||
let isParsingInfo = false;
|
||
let isReadingInfo = false;
|
||
let isReadingAuthor = false;
|
||
let isReadingArtist = false;
|
||
let isReadingStatus = false;
|
||
let isParsingChapterList = false;
|
||
let isReadingChapter = false;
|
||
let isReadingChapterInfo = 0;
|
||
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']?.includes('ts-post-image' || 'wp-post-image')
|
||
) {
|
||
novel.name = attribs['title'];
|
||
novel.cover = attribs['data-src'] || attribs['src'] || defaultCover;
|
||
} // genres
|
||
else if (
|
||
attribs['class'] === 'genxed' ||
|
||
attribs['class'] === 'sertogenre'
|
||
) {
|
||
isParsingGenres = true;
|
||
} else if (isParsingGenres && name === 'a') {
|
||
isReadingGenre = true;
|
||
} // summary
|
||
else if (
|
||
name === 'div' &&
|
||
(attribs['class'] === 'entry-content' ||
|
||
attribs['itemprop'] === 'description')
|
||
) {
|
||
isReadingSummary = true;
|
||
} // author and status
|
||
else if (attribs['class'] === 'spe' || attribs['class'] === 'serl') {
|
||
isParsingInfo = true;
|
||
} else if (isParsingInfo && name === 'span') {
|
||
isReadingInfo = true;
|
||
} else if (name === 'div' && attribs['class'] === 'sertostat') {
|
||
isParsingInfo = true;
|
||
isReadingInfo = true;
|
||
isReadingStatus = true;
|
||
}
|
||
// chapters
|
||
else if (attribs['class'] === 'eplister eplisterfull') {
|
||
isParsingChapterList = true;
|
||
} else if (isParsingChapterList && name === 'li') {
|
||
isReadingChapter = true;
|
||
} else if (isReadingChapter) {
|
||
if (name === 'a') {
|
||
tempChapter.path = attribs['href'].replace(baseURL, '').trim();
|
||
} else if (attribs['class'] === 'epl-num') {
|
||
isReadingChapterInfo = 1;
|
||
} else if (attribs['class'] === 'epl-title') {
|
||
isReadingChapterInfo = 2;
|
||
} else if (attribs['class'] === 'epl-date') {
|
||
isReadingChapterInfo = 3;
|
||
} else if (attribs['class'] === 'epl-price') {
|
||
isReadingChapterInfo = 4;
|
||
}
|
||
}
|
||
},
|
||
ontext(data) {
|
||
// genres
|
||
if (isParsingGenres) {
|
||
if (isReadingGenre) {
|
||
novel.genres += data + ', ';
|
||
}
|
||
} // summary
|
||
else if (isReadingSummary) {
|
||
novel.summary += data.trim();
|
||
} // author and status
|
||
else if (isParsingInfo) {
|
||
if (isReadingInfo) {
|
||
const detailName = data.toLowerCase().replace(':', '').trim();
|
||
|
||
if (isReadingAuthor) {
|
||
novel.author += data || 'Unknown';
|
||
} else if (isReadingArtist) {
|
||
novel.artist += data || 'Unknown';
|
||
} else if (isReadingStatus) {
|
||
switch (detailName) {
|
||
case 'مكتملة':
|
||
case 'completed':
|
||
case 'complété':
|
||
case 'completo':
|
||
case 'completado':
|
||
case 'tamamlandı':
|
||
novel.status = NovelStatus.Completed;
|
||
break;
|
||
case 'مستمرة':
|
||
case 'ongoing':
|
||
case 'en cours':
|
||
case 'em andamento':
|
||
case 'en progreso':
|
||
case 'devam ediyor':
|
||
novel.status = NovelStatus.Ongoing;
|
||
break;
|
||
case 'متوقفة':
|
||
case 'hiatus':
|
||
case 'en pause':
|
||
case 'hiato':
|
||
case 'pausa':
|
||
case 'pausado':
|
||
case 'duraklatıldı':
|
||
novel.status = NovelStatus.OnHiatus;
|
||
break;
|
||
default:
|
||
novel.status = NovelStatus.Unknown;
|
||
break;
|
||
}
|
||
}
|
||
|
||
switch (detailName) {
|
||
case 'الكاتب':
|
||
case 'author':
|
||
case 'auteur':
|
||
case 'autor':
|
||
case 'yazar':
|
||
isReadingAuthor = true;
|
||
break;
|
||
case 'الحالة':
|
||
case 'status':
|
||
case 'statut':
|
||
case 'estado':
|
||
case 'durum':
|
||
isReadingStatus = true;
|
||
break;
|
||
case 'الفنان':
|
||
case 'artist':
|
||
case 'artiste':
|
||
case 'artista':
|
||
case 'çizer':
|
||
isReadingArtist = true;
|
||
break;
|
||
}
|
||
}
|
||
} // chapters
|
||
else if (isParsingChapterList) {
|
||
if (isReadingChapter) {
|
||
if (isReadingChapterInfo === 1) {
|
||
extractChapterNumber(data, tempChapter);
|
||
} else if (isReadingChapterInfo === 2) {
|
||
tempChapter.name =
|
||
data
|
||
.match(
|
||
RegExp(
|
||
`^${novel.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*(.+)`,
|
||
),
|
||
)?.[1]
|
||
?.trim() || data.trim();
|
||
if (!tempChapter.chapterNumber) {
|
||
extractChapterNumber(data, tempChapter);
|
||
}
|
||
} else if (isReadingChapterInfo === 3) {
|
||
tempChapter.releaseTime = data; //new Date(data).toISOString();
|
||
} else if (isReadingChapterInfo === 4) {
|
||
const detailName = data.toLowerCase().trim();
|
||
switch (detailName) {
|
||
case 'free':
|
||
case 'gratuit':
|
||
case 'مجاني':
|
||
case 'livre':
|
||
case '':
|
||
isPaidChapter = false;
|
||
break;
|
||
default:
|
||
isPaidChapter = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
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 === 'br') {
|
||
novel.summary += '\n';
|
||
} else if (name === 'div') {
|
||
isReadingSummary = false;
|
||
}
|
||
} // author and status
|
||
else if (isParsingInfo) {
|
||
if (isReadingInfo) {
|
||
if (name === 'span') {
|
||
isReadingInfo = false;
|
||
if (isReadingAuthor && novel.author) {
|
||
isReadingAuthor = false;
|
||
} else if (isReadingArtist && novel.artist) {
|
||
isReadingArtist = false;
|
||
} else if (isReadingStatus && novel.status !== '') {
|
||
isReadingStatus = false;
|
||
}
|
||
}
|
||
} else if (name === 'div') {
|
||
isParsingInfo = false;
|
||
novel.author = novel.author?.trim();
|
||
novel.artist = novel.artist?.trim();
|
||
}
|
||
} // chapters
|
||
else if (isParsingChapterList) {
|
||
if (isReadingChapter) {
|
||
if (isReadingChapterInfo === 1) {
|
||
isReadingChapterInfo = 0;
|
||
} else if (isReadingChapterInfo === 2) {
|
||
isReadingChapterInfo = 0;
|
||
} else if (isReadingChapterInfo === 3) {
|
||
isReadingChapterInfo = 0;
|
||
} else if (isReadingChapterInfo === 4) {
|
||
isReadingChapterInfo = 0;
|
||
} else if (name === 'li') {
|
||
isReadingChapter = false;
|
||
if (!tempChapter.chapterNumber) tempChapter.chapterNumber = 0;
|
||
if (!isPaidChapter) chapters.push(tempChapter);
|
||
tempChapter = {} as Plugin.ChapterItem;
|
||
}
|
||
} else if (name === 'ul') {
|
||
isParsingChapterList = false;
|
||
}
|
||
}
|
||
},
|
||
});
|
||
|
||
parser.write(html);
|
||
parser.end();
|
||
|
||
if (chapters.length) {
|
||
if (this.options?.reverseChapters) chapters.reverse();
|
||
novel.chapters = chapters;
|
||
}
|
||
|
||
return novel;
|
||
}
|
||
|
||
async parseChapter(chapterPath: string): Promise<string> {
|
||
const data = await this.safeFecth(this.site + chapterPath, false);
|
||
const chapterText = data.match(
|
||
/<div.*class="epcontent ([\s\S]*?)<div.*class="bottomnav"/g,
|
||
)?.[0];
|
||
if (this.options?.customJs) {
|
||
try {
|
||
const $ = load(chapterText || data);
|
||
// CustomJS HERE
|
||
return $.html();
|
||
} catch (error) {
|
||
console.error('Error executing customJs:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
return chapterText || '';
|
||
}
|
||
|
||
async searchNovels(
|
||
searchTerm: string,
|
||
page: number,
|
||
): Promise<Plugin.NovelItem[]> {
|
||
const url = this.site + 'page/' + page + '/?s=' + searchTerm;
|
||
const html = await this.safeFecth(url, true);
|
||
return this.parseNovels(html);
|
||
}
|
||
|
||
fetchImage = fetchFile;
|
||
}
|
||
|
||
function extractChapterNumber(data: string, tempChapter: Plugin.ChapterItem) {
|
||
const tempChapterNumber = data.match(/(\d+)$/);
|
||
if (tempChapterNumber && tempChapterNumber[0]) {
|
||
tempChapter.chapterNumber = parseInt(tempChapterNumber[0]);
|
||
}
|
||
}
|