diff --git a/icons/multisrc/madara/worldnovel/icon.png b/icons/multisrc/madara/worldnovel/icon.png index d54a996..f5b90a4 100644 Binary files a/icons/multisrc/madara/worldnovel/icon.png and b/icons/multisrc/madara/worldnovel/icon.png differ diff --git a/icons/src/en/foxteller/icon.png b/icons/src/en/foxteller/icon.png new file mode 100644 index 0000000..3cc6626 Binary files /dev/null and b/icons/src/en/foxteller/icon.png differ diff --git a/package-lock.json b/package-lock.json index 555cc1c..d625005 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9a7c720..4a85378 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/plugins/english/foxteller.ts b/plugins/english/foxteller.ts new file mode 100644 index 0000000..dfad87a --- /dev/null +++ b/plugins/english/foxteller.ts @@ -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, + ): Promise { + 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(/
([\s\S]*?)<\/div>/g) || []; + div.forEach(elements => { + const [, novelUrl, novelName] = + elements.match(//) || []; + + if (novelName && novelUrl) { + const novelCover = elements.match( + //, + ); + + novels.push({ + name: novelName, + cover: novelCover?.[1] || defaultCover, + path: novelUrl.split('/')[4], + }); + } + }); + + return novels; + } + + async parseNovel(novelPath: string): Promise { + 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 { + 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 { + 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(/([\s\S]*?)<\/a>/g) || []; + items.forEach(elements => { + const novelUrl = elements.match(/(.*?)<\/span>/, + )?.[1]; + + if (name && path) { + const cover = + elements.match(//)?.[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; +} diff --git a/scripts/multisrc/lightnovelwp/template.ts b/scripts/multisrc/lightnovelwp/template.ts index 97c769c..0ed9f14 100644 --- a/scripts/multisrc/lightnovelwp/template.ts +++ b/scripts/multisrc/lightnovelwp/template.ts @@ -77,8 +77,8 @@ class LightNovelWPPlugin implements Plugin.PluginBase { const articles = html.match(//g) || []; articles.forEach(article => { - const novelName = article.match(/