Feat(plugin)Add Foxteller (#984)

* test

* optimization

* terser@5.31.0 => terser@5.31.1
This commit is contained in:
Rider21
2024-06-12 15:12:52 +03:00
committed by GitHub
parent 265e20fc3c
commit c6c5c99c1c
7 changed files with 325 additions and 19 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "lnreader-plugins",
"version": "2.1.0",
"version": "2.2.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "lnreader-plugins",
"version": "2.1.0",
"version": "2.2.0",
"license": "MIT",
"dependencies": {
"body-parser": "^1.20.2",
@@ -22,7 +22,7 @@
"protobufjs": "^7.2.6",
"qs": "^6.11.2",
"sanitize-html": "^2.12.1",
"terser": "^5.31.0",
"terser": "^5.31.1",
"ts-node": "^10.9.2",
"tsc-alias": "^1.8.8",
"typescript": "^5.3.3",
@@ -4121,9 +4121,9 @@
}
},
"node_modules/terser": {
"version": "5.31.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.31.0.tgz",
"integrity": "sha512-Q1JFAoUKE5IMfI4Z/lkE/E6+SwgzO+x4tq4v1AyBLRj8VSYvRO6A/rQrPg1yud4g0En9EKI1TvFRF2tQFcoUkg==",
"version": "5.31.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.31.1.tgz",
"integrity": "sha512-37upzU1+viGvuFtBo9NPufCb9dwM0+l9hMxYyWfBA+fbwrPqNJAhbZ6W47bBFnZHKHTUBnMvi87434qq+qnxOg==",
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2",
@@ -7708,9 +7708,9 @@
}
},
"terser": {
"version": "5.31.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.31.0.tgz",
"integrity": "sha512-Q1JFAoUKE5IMfI4Z/lkE/E6+SwgzO+x4tq4v1AyBLRj8VSYvRO6A/rQrPg1yud4g0En9EKI1TvFRF2tQFcoUkg==",
"version": "5.31.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.31.1.tgz",
"integrity": "sha512-37upzU1+viGvuFtBo9NPufCb9dwM0+l9hMxYyWfBA+fbwrPqNJAhbZ6W47bBFnZHKHTUBnMvi87434qq+qnxOg==",
"requires": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2",
+1 -1
View File
@@ -32,7 +32,7 @@
"protobufjs": "^7.2.6",
"qs": "^6.11.2",
"sanitize-html": "^2.12.1",
"terser": "^5.31.0",
"terser": "^5.31.1",
"ts-node": "^10.9.2",
"tsc-alias": "^1.8.8",
"typescript": "^5.3.3",
+312
View File
@@ -0,0 +1,312 @@
import { Plugin } from '@typings/plugin';
import { Parser } from 'htmlparser2';
import { FilterTypes, Filters } from '@libs/filterInputs';
import { defaultCover } from '@libs/defaultCover';
import { fetchApi } from '@libs/fetch';
import { NovelStatus } from '@libs/novelStatus';
class Foxteller implements Plugin.PluginBase {
id = 'foxteller';
name = 'Foxteller';
site = 'https://www.foxteller.com';
version = '1.0.0';
icon = 'src/en/foxteller/icon.png';
async popularNovels(
pageNo: number,
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
const url =
this.site + '/library?sort=' + filters?.order?.value || 'popularity';
const body = await fetchApi(url).then(res => res.text());
const novels: Plugin.NovelItem[] = [];
const div = body.match(/<div class="col-md-6">([\s\S]*?)<\/div>/g) || [];
div.forEach(elements => {
const [, novelUrl, novelName] =
elements.match(/<a href="(.*?)" title="(.*?)">/) || [];
if (novelName && novelUrl) {
const novelCover = elements.match(
/<img class="img-fluid" src="(.*?)".*>/,
);
novels.push({
name: novelName,
cover: novelCover?.[1] || defaultCover,
path: novelUrl.split('/')[4],
});
}
});
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const body = await fetchApi(this.resolveUrl(novelPath)).then(res =>
res.text(),
);
const novel: Plugin.SourceNovel = {
path: novelPath,
name: '',
genres: '',
summary: '',
status: '',
chapters: [] as Plugin.ChapterItem[],
};
let isParsingGenres = false;
let isReadingGenre = false;
let isReadingSummary = false;
let isParsingInfo = false;
let isReadingInfo = false;
let isParsingChapterList = false;
let isReadingChapter = false;
let isPaidChapter = false;
const chapters: Plugin.ChapterItem[] = [];
let tempChapter = {} as Plugin.ChapterItem;
const parser = new Parser({
onopentag(name, attribs) {
// name and cover
if (!novel.cover && attribs['class'] === 'img-fluid') {
novel.name = attribs['alt'];
novel.cover = attribs['src'] || defaultCover;
} // genres
else if (name === 'div' && attribs['class'] === 'novel-genres') {
isParsingGenres = true;
} else if (isParsingGenres && name === 'li') {
isReadingGenre = true;
} // summary
else if (name === 'div' && attribs['class'] === 'novel-description') {
isReadingSummary = true;
} // status
else if (name === 'div' && attribs['class'] === 'novel-tags') {
isParsingInfo = true;
} else if (isParsingInfo && name === 'li') {
isReadingInfo = true;
}
// chapters
else if (name === 'div' && attribs['class'] === 'col-md-6') {
isParsingChapterList = true;
} else if (isParsingChapterList && name === 'a') {
isReadingChapter = true;
tempChapter.chapterNumber = chapters.length + 1;
tempChapter.path = novelPath + '/' + attribs['href'].split('/')[5];
} else if (
isReadingChapter &&
name === 'i' &&
attribs['class']?.includes('lock')
) {
isPaidChapter = true;
}
},
ontext(data) {
// genres
if (isParsingGenres) {
if (isReadingGenre) {
novel.genres += data + ', ';
}
} // summary
else if (isReadingSummary) {
novel.summary += data.trim();
} // status
else if (isParsingInfo) {
if (isReadingInfo) {
const detailName = data.toLowerCase().trim();
switch (detailName) {
case 'completed':
novel.status = NovelStatus.Completed;
break;
case 'ongoing':
novel.status = NovelStatus.Ongoing;
break;
case 'hiatus':
novel.status = NovelStatus.OnHiatus;
break;
default:
novel.status = NovelStatus.Unknown;
break;
}
}
}
// chapters
else if (isParsingChapterList) {
if (isReadingChapter) {
tempChapter.name = (tempChapter.name || '') + data;
}
}
},
onclosetag(name) {
// genres
if (isParsingGenres) {
if (isReadingGenre) {
isReadingGenre = false; // stop reading genre
} else {
isParsingGenres = false; // stop parsing genres
novel.genres = novel.genres?.slice(0, -2); // remove trailing comma
}
} // summary
else if (isReadingSummary) {
if (name === 'hr' || name === 'p') {
novel.summary += '\n';
} else if (name === 'div') {
isReadingSummary = false;
}
} // status
else if (isParsingInfo) {
if (name === 'li') {
isReadingInfo = false;
} else if (name === 'div') {
isParsingInfo = false;
}
} // chapters
else if (isParsingChapterList) {
if (isReadingChapter) {
if (name === 'li') {
if (isPaidChapter) {
isPaidChapter = false;
} else {
tempChapter.name = tempChapter.name.trim();
chapters.push(tempChapter);
}
isReadingChapter = false;
tempChapter = {} as Plugin.ChapterItem;
}
} else if (name === 'ul') {
isParsingChapterList = false;
}
}
},
});
parser.write(body);
parser.end();
novel.chapters = chapters;
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const res = await fetchApi(this.resolveUrl(chapterPath));
const body = await res.text();
const novelID = chapterPath.split('/')[0];
const chapterID = body.match(/'chapter_id': '([\d]+)'/)?.[1];
if (!chapterID) throw new Error('No chapter found');
const { aux } = await fetchApi(this.site + '/aux_dem', {
method: 'post',
headers: {
Accept: 'application/json, text/plain, */*',
'Accept-Language': 'ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3',
'Content-Type': 'application/json;charset=utf-8',
},
Referer: res.url,
body: JSON.stringify({ 'x1': novelID, 'x2': chapterID }),
}).then(res => res.json());
if (aux && typeof aux === 'string') {
const base64 = aux.replace(/%R([a-f])&/g, (match, code) => {
switch (code) {
case 'a':
return 'A';
case 'c':
return 'B';
case 'b':
return 'C';
case 'd':
return 'D';
case 'f':
return 'E';
case 'e':
return 'F';
default:
return match;
}
});
return decodeURIComponent(escape(decodeBase64(base64)));
}
throw new Error('This chapter is closed');
}
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
const body = await fetchApi(this.site + '/search', {
method: 'post',
headers: {
Accept: 'application/json, text/plain, */*',
'Accept-Language': 'ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3',
'Content-Type': 'application/json;charset=utf-8',
},
Referer: this.site,
body: JSON.stringify({ query: searchTerm }),
}).then(res => res.text());
const novels: Plugin.NovelItem[] = [];
const items = body.match(/<a.*>([\s\S]*?)<\/a>/g) || [];
items.forEach(elements => {
const novelUrl = elements.match(/<a href="(.*?)"/)?.[1] || '';
const path = novelUrl.split('/')[4];
const name = elements.match(
/<span class="ellipsis-1">(.*?)<\/span>/,
)?.[1];
if (name && path) {
const cover =
elements.match(/<img src="(.*?)".*>/)?.[1] || defaultCover;
novels.push({ name, cover, path });
}
});
return novels;
}
resolveUrl = (path: string, isNovel?: boolean) =>
this.site + '/novel/' + path;
filters = {
order: {
value: 'popularity',
label: 'Order by',
options: [
{ label: 'Popular Novels', value: 'popularity' },
{ label: 'New Novels', value: 'newest' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
}
export default new Foxteller();
const base64Characters =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
function decodeBase64(encodedString: string) {
let output = '';
let i = 0;
while (i < encodedString.length) {
const enc1 = base64Characters.indexOf(encodedString.charAt(i++));
const enc2 = base64Characters.indexOf(encodedString.charAt(i++));
const enc3 = base64Characters.indexOf(encodedString.charAt(i++));
const enc4 = base64Characters.indexOf(encodedString.charAt(i++));
const chr1 = (enc1 << 2) | (enc2 >> 4);
output += String.fromCharCode(chr1);
if (enc3 !== 64) {
const chr2 = ((enc2 & 15) << 4) | (enc3 >> 2);
output += String.fromCharCode(chr2);
}
if (enc4 !== 64) {
const chr3 = ((enc3 & 3) << 6) | enc4;
output += String.fromCharCode(chr3);
}
}
return output;
}
+3 -6
View File
@@ -77,8 +77,8 @@ class LightNovelWPPlugin implements Plugin.PluginBase {
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];
const [, novelUrl, novelName] =
article.match(/<a href="(.*?)".*title="(.*?)"/) || [];
if (novelName && novelUrl) {
const novelCover =
@@ -148,10 +148,7 @@ class LightNovelWPPlugin implements Plugin.PluginBase {
const parser = new Parser({
onopentag(name, attribs) {
// name and cover
if (
!novel.cover &&
attribs['class']?.includes('ts-post-image' || 'wp-post-image')
) {
if (!novel.cover && attribs['class']?.includes('ts-post-image')) {
novel.name = attribs['title'];
novel.cover = attribs['data-src'] || attribs['src'] || defaultCover;
} // genres
-3
View File
@@ -4,13 +4,10 @@ import * as fs from 'fs';
const config = {
compress: {
arrows: false,
booleans_as_integers: true,
},
mangle: {},
ecma: 5, // specify one of: 5, 2015, 2016, etc.
enclose: false, // or specify true, or "args:values"
keep_classnames: false,
keep_fnames: false,
module: true,
toplevel: true,
};