diff --git a/public/static/multisrc/novelcool/novelcool/icon.png b/public/static/multisrc/novelcool/novelcool/icon.png new file mode 100644 index 0000000..568613b Binary files /dev/null and b/public/static/multisrc/novelcool/novelcool/icon.png differ diff --git a/scripts/multisrc/novelcool/generator.js b/scripts/multisrc/novelcool/generator.js new file mode 100644 index 0000000..d9d05c9 --- /dev/null +++ b/scripts/multisrc/novelcool/generator.js @@ -0,0 +1,40 @@ +import list from './sources.json' with { type: 'json' }; +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// https://app.novelcool.com/ +const app = { + userAgent: + 'Android/Package:com.zuoyou.novel - Version Name:2.3 - Phone Info:sdk_gphone_x86_64(Android Version:13)', + package_name: 'com.zuoyou.novel', + appId: '202201290625004', + secret: 'c73a8590641781f203660afca1d37ada', +}; + +export const generateAll = function () { + list.sort((a, b) => a.id.length - b.id.length); + return list.map(source => generator(source)); +}; + +const generator = function generator(source) { + const NovelCoolTemplate = readFileSync(__dirname + '/template.ts', { + encoding: 'utf-8', + }); + source.options.app = app; + + const pluginScript = ` + ${NovelCoolTemplate} + +const plugin = new NovelCoolPlugin(${JSON.stringify(source)}); +export default plugin; + `.trim(); + + return { + lang: source.options.lang, + filename: source.sourceName, + pluginScript, + }; +}; diff --git a/scripts/multisrc/novelcool/sources.json b/scripts/multisrc/novelcool/sources.json new file mode 100644 index 0000000..ea4aa15 --- /dev/null +++ b/scripts/multisrc/novelcool/sources.json @@ -0,0 +1,14 @@ +[ + { + "id": "novelcool", + "sourceName": "NovelCool", + "sourceSite": "https://www.novelcool.com", + "options": { "lang": "English", "langCode": "en" } + }, + { + "id": "novelcool-ru", + "sourceName": "NovelCool (RU)", + "sourceSite": "https://ru.novelcool.com", + "options": { "lang": "Russian", "langCode": "ru" } + } +] diff --git a/scripts/multisrc/novelcool/template.ts b/scripts/multisrc/novelcool/template.ts new file mode 100644 index 0000000..ace9141 --- /dev/null +++ b/scripts/multisrc/novelcool/template.ts @@ -0,0 +1,321 @@ +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@typings/plugin'; +import { NovelStatus } from '@libs/novelStatus'; +import { Filters, FilterTypes } from '@libs/filterInputs'; +import dayjs from 'dayjs'; + +type NovelCoolOptions = { + lang: string; + langCode: string; + app: Record; +}; + +export type NovelCoolMetadata = { + id: string; + sourceSite: string; + sourceName: string; + options: NovelCoolOptions; +}; + +class NovelCoolPlugin implements Plugin.PluginBase { + id: string; + name: string; + icon: string; + site: string; + mainUrl: string; + version: string; + options: NovelCoolOptions; + filters: Filters; + + constructor(metadata: NovelCoolMetadata) { + this.id = metadata.id; + this.name = metadata.sourceName; + this.site = metadata.sourceSite; + this.icon = 'multisrc/novelcool/novelcool/icon.png'; + this.mainUrl = 'https://api.novelcool.com'; + this.version = '1.0.0'; + this.options = metadata.options ?? ({} as NovelCoolOptions); + this.filters = { + sortby: { + label: 'Order by', + value: 'hot', + options: [ + { label: 'Hottest', value: 'hot' }, + { label: 'Latest', value: 'latest' }, + { label: 'New Books', value: 'new_book' }, + ], + type: FilterTypes.Picker, + }, + }; + } + + async popularNovels( + page: number, + { + filters, + showLatestNovels, + }: Plugin.PopularNovelsOptions, + ): Promise { + const sortby = showLatestNovels + ? 'latest' + : filters?.sortby?.value || 'hot'; + + const { list }: { list: Novel[] } = await fetchApi( + this.mainUrl + '/elite/' + sortby, + { + headers: { + 'User-Agent': this.options.app.userAgent, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'post', + body: new URLSearchParams({ + appId: this.options.app.appId, + secret: this.options.app.secret, + package_name: this.options.app.package_name, + lc_type: 'novel', + lang: this.options.langCode, + page: page.toString(), + page_size: '20', + }).toString(), + }, + ).then(res => res.json()); + const novels: Plugin.NovelItem[] = []; + + list.forEach(novel => + novels.push({ + name: novel.name, + cover: novel.cover, + path: novel.visit_path + '?id=' + novel.id, + }), + ); + + return novels; + } + + async parseNovel(novelPath: string): Promise { + const book_id = novelPath.split('?id=')[1]; + const { info }: { info: Novel } = await fetchApi( + this.mainUrl + '/book/info/', + { + headers: { + 'User-Agent': this.options.app.userAgent, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'post', + body: new URLSearchParams({ + book_id, + appId: this.options.app.appId, + secret: this.options.app.secret, + package_name: this.options.app.package_name, + lang: this.options.langCode, + }).toString(), + }, + ).then(res => res.json()); + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: info.name, + cover: info.cover, + genres: info.category_list.join(','), + summary: info.intro, + author: info.author, + artist: info.artist, + status: + info.completed === 'YES' ? NovelStatus.Completed : NovelStatus.Ongoing, + rating: (info.rate_star && parseFloat(info.rate_star)) || undefined, + }; + const chapters: Plugin.ChapterItem[] = []; + + const { list }: { list: ChapterNext[] } = await fetchApi( + this.mainUrl + '/chapter/book_list/', + { + headers: { + 'User-Agent': this.options.app.userAgent, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'post', + body: new URLSearchParams({ + book_id, + appId: this.options.app.appId, + secret: this.options.app.secret, + package_name: this.options.app.package_name, + lang: this.options.langCode, + }).toString(), + }, + ).then(res => res.json()); + + list.forEach(chapter => { + if (!chapter.is_locked) { + chapters.push({ + name: chapter.title, + path: chapter.book_id + '/' + chapter.id, + releaseTime: dayjs(parseInt(chapter.last_modify, 10) * 1000).format( + 'LLL', + ), + chapterNumber: parseInt(chapter.order_id, 10), + }); + } + }); + + novel.chapters = chapters.reverse(); + return novel; + } + + async parseChapter(chapterPath: string): Promise { + const [, chapter_id] = chapterPath.split('/'); + const { info }: { info: Chapter } = await fetchApi( + this.mainUrl + '/chapter/info/', + { + headers: { + 'User-Agent': this.options.app.userAgent, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'post', + body: new URLSearchParams({ + chapter_id, + appId: this.options.app.appId, + secret: this.options.app.secret, + package_name: this.options.app.package_name, + lang: this.options.langCode, + }).toString(), + }, + ).then(res => res.json()); + + return info.content; + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + const { list }: { list: Novel[] } = await fetchApi( + this.mainUrl + '/book/search/', + { + headers: { + 'User-Agent': this.options.app.userAgent, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'post', + body: new URLSearchParams({ + appId: this.options.app.appId, + secret: this.options.app.secret, + package_name: this.options.app.package_name, + keyword: searchTerm, + lc_type: 'novel', + lang: this.options.langCode, + page: pageNo.toString(), + page_size: '20', + }).toString(), + }, + ).then(res => res.json()); + const novels: Plugin.NovelItem[] = []; + + list.forEach(novel => + novels.push({ + name: novel.name, + cover: novel.cover, + path: novel.visit_path + '?id=' + novel.id, + }), + ); + + return novels; + } + + resolveUrl = (path: string, isNovel?: boolean) => + this.site + (isNovel ? '/novel/' : '/chapter/') + path.split('?id=')[0]; +} + +type Novel = { + id: string; + lang: string; + url: string; + visit_path: string; + name: string; + alternative: string; + publish_year: string; + warning: string; + char_index: string; + author: string; + artist: string; + intro: string; + tags?: string; + category: string; + completed: 'YES' | 'NO'; + last_chapter_id: string; + last_chapter_title: string; + all_views: string; + rate_star: string; + rate_mark: string; + rate_num: string; + follow_num: string; + pic_num: string; + make_time: Date; + copy_limit: string; + copyright: string; + user_id: string; + show_it: 'ALLOW'; + type: string; + elite: string; + book_id: string; + is_novel: string; + is_new: string; + is_hot: string; + cover: string; + og_url?: string; + no: string; + last_url?: string; + category_str: string; + category_list: string[]; + star_list: any[]; + int_mark: string; + time: string; + show_ads: string; + first_chapter_id?: string; + first_chapter_type?: number; + is_following: boolean; + copyright_limit: boolean; + share_title?: string; +}; + +type Chapter = { + id: string; + user_id: string; + vol_id: string; + lang: string; + order_id: string; + book_id: string; + title: string; + type: number; + url: string; + content: string; + content_size: string; + fixed: string; + group_name: string; + last_modify: string; + add_time: Date; + prev: ChapterNext; + next: ChapterNext; + blank: boolean; + o_url: string; + is_locked: boolean; + show_url: boolean; + is_following: boolean; + share_title: string; +}; + +type ChapterNext = { + id: string; + book_id: string; + type: number; + lang: string; + vol_id: string; + order_id: string; + title: string; + last_modify: string; + url: string; + no: string; + is_locked: boolean; + is_new: boolean; + tf_time: string; + url_pre?: string; +};