diff --git a/plugins/english/mynovels.ts b/plugins/english/mynovels.ts new file mode 100644 index 000000000..6c4027dff --- /dev/null +++ b/plugins/english/mynovels.ts @@ -0,0 +1,436 @@ +import { load as parseHTML } from 'cheerio'; +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { NovelStatus } from '@libs/novelStatus'; +import { Filters, FilterTypes } from '@libs/filterInputs'; +import { defaultCover } from '@libs/defaultCover'; +import dayjs from 'dayjs'; +import { storage } from '@libs/storage'; + +class Mynovels implements Plugin.PagePlugin { + id = 'mynovels'; + name = 'Mynovels'; + version = '1.0.0'; + icon = 'src/en/mynovels/icon.png'; + site = 'https://mynovels.su/'; + + hideLocked = storage.get('hideLocked'); + pluginSettings = { + hideLocked: { + value: '', + label: 'Hide locked chapters', + type: 'Switch', + }, + }; + + private getChunkSize(totalChapters: number): number { + return totalChapters > 500 ? 300 : 100; + } + + private parseTotalChapters( + loadedCheerio: ReturnType, + ): number { + let totalChapters = 0; + loadedCheerio('#select-pagination-chapter > option').each((_, el) => { + const text = loadedCheerio(el).text().trim(); + const match = text.match(/([0-9]+)\s*-\s*([0-9]+)/); + if (match) { + const end = parseInt(match[2], 10); + if (end > totalChapters) totalChapters = end; + } + }); + return ( + totalChapters || + loadedCheerio('#select-pagination-chapter > option').length * 50 || + 1 + ); + } + + async popularNovels( + pageNo: number, + { + showLatestNovels, + filters, + }: Plugin.PopularNovelsOptions, + ): Promise { + let url = `${this.site}catalog/`; + if (showLatestNovels) { + url += `?ordering=-time_updated&page=${pageNo}`; + } else if (filters) { + const params = new URLSearchParams(); + for (const country of filters.country.value) { + params.append('country', country); + } + for (const genre of filters.genres.value) { + params.append('genre', genre); + } + for (const translation of filters.translation.value) { + params.append('translation', translation); + } + for (const status of filters.status.value) { + params.append('status', status); + } + for (const novel_type of filters.novel_type.value) { + params.append('type', novel_type); + } + params.append('ordering', filters.sort.value); + params.append('page', pageNo.toString()); + url += `?${params.toString()}`; + } else { + url += `?&ordering=popularity&page=${pageNo}`; + } + + const body = await fetchApi(url).then(r => r.text()); + const loadedCheerio = parseHTML(body); + + const novels: Plugin.NovelItem[] = []; + + loadedCheerio('a.item').each((idx, ele) => { + const el = loadedCheerio(ele); + const novelName = el.find('div.title').text().trim(); + const novelUrl = ele.attribs.href; + const bareNovelCover = el.find('img').attr('src'); + const novelCover = bareNovelCover + ? this.site + bareNovelCover.replace(/^\//, '') + : defaultCover; + if (!novelUrl) return; + + novels.push({ + name: novelName, + cover: novelCover, + path: novelUrl.replace(/^\//, ''), + }); + }); + + return novels; + } + + async parseNovel( + novelPath: string, + ): Promise { + const body = await fetchApi(this.site + novelPath).then(r => r.text()); + const loadedCheerio = parseHTML(body); + + const coverSrc = loadedCheerio('.poster > img').attr('src'); + const totalChapters = this.parseTotalChapters(loadedCheerio); + const chunkSize = this.getChunkSize(totalChapters); + const novel: Plugin.SourceNovel & { totalPages: number } = { + path: novelPath, + name: loadedCheerio('h1').text().trim() || 'Untitled', + cover: coverSrc ? this.site + coverSrc.replace(/^\//, '') : defaultCover, + summary: loadedCheerio('section.text-info.section > p').text().trim(), + totalPages: Math.ceil(totalChapters / chunkSize) || 1, + chapters: [], + }; + + const info = loadedCheerio('div.mini-info > .item').toArray(); + let status = ''; + let translation = ''; + for (const child of info) { + const el = loadedCheerio(child); + const type = el.find('.sub-header').text().trim(); + if (type === 'Status') { + status = el.find('div.info').text().trim().toLowerCase(); + } + if (type === 'Translation') { + translation = el.find('div.info').text().trim().toLowerCase(); + } + if (type === 'Author') { + novel.author = el.find('div.info').text().trim(); + } + if (type === 'Genres') { + novel.genres = el + .find('div.info > a') + .map((i, a) => loadedCheerio(a).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; + } + + private async fetchSitePageChapters( + novelPath: string, + csrftoken: string, + bookId: string, + sitePage: number, + pluginPage: string, + ): Promise { + const r = await fetchApi( + `${this.site}book/ajax/chapter-pagination?csrfmiddlewaretoken=${csrftoken}&book_id=${bookId}&page=${sitePage}`, + { + headers: { + 'Host': this.site.replace('https://', '').replace('/', ''), + 'Referer': this.site + novelPath, + 'X-Requested-With': 'XMLHttpRequest', + }, + }, + ); + + let chaptersRaw; + try { + chaptersRaw = (await r.json()).html; + } catch { + return []; + } + + const chapter: Plugin.ChapterItem[] = []; + const $ = parseHTML(chaptersRaw); + + $('a').each((idx, ele) => { + const el = $(ele); + const title = el.find('.title').text().trim(); + const isLocked = Boolean(el.find('.cost').text().trim()); + if (this.hideLocked && isLocked) return; + + let date; + try { + date = dayjs( + el.find('.date').text().trim(), + 'DD.MM.YYYY', + ).toISOString(); + } catch { + // linter happy + } + + const chapterName = isLocked ? '🔒 ' + title : title; + const chapterUrl = ele.attribs.href?.replace(/^\//, '') || ''; + chapter.push({ + name: chapterName, + path: chapterUrl, + page: pluginPage, + releaseTime: date, + }); + }); + + return chapter.reverse(); + } + + async parsePage(novelPath: string, page: string): Promise { + 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 loadedCheerio = parseHTML(rawBody); + + const totalChapters = this.parseTotalChapters(loadedCheerio); + const chunkSize = this.getChunkSize(totalChapters); + const pluginPageNum = parseInt(page, 10); + + const desiredStartIdx = (pluginPageNum - 1) * chunkSize + 1; + const desiredEndIdx = Math.min(pluginPageNum * chunkSize, totalChapters); + + const sitePagesToFetch: { + sp: number; + rangeStart: number; + rangeEnd: number; + }[] = []; + loadedCheerio('#select-pagination-chapter > option').each((_, el) => { + const sp = parseInt(loadedCheerio(el).val() || '1', 10); + const text = loadedCheerio(el).text().trim(); + const match = text.match(/([0-9]+)\s*-\s*([0-9]+)/); + if (match) { + const rangeStart = parseInt(match[1], 10); + const rangeEnd = parseInt(match[2], 10); + if (rangeStart <= desiredEndIdx && rangeEnd >= desiredStartIdx) { + sitePagesToFetch.push({ sp, rangeStart, rangeEnd }); + } + } + }); + + sitePagesToFetch.sort((a, b) => a.rangeStart - b.rangeStart); + + const results = await Promise.all( + sitePagesToFetch.map(p => + this.fetchSitePageChapters(novelPath, csrftoken, bookId, p.sp, page), + ), + ); + + const fetchedChapters = results.flat(); + if (sitePagesToFetch.length === 0) { + return { chapters: fetchedChapters }; + } + + const sliceOffset = desiredStartIdx - sitePagesToFetch[0].rangeStart; + const countNeeded = desiredEndIdx - desiredStartIdx + 1; + const chapters = fetchedChapters.slice( + sliceOffset, + sliceOffset + countNeeded, + ); + + return { chapters }; + } + + async parseChapter(chapterPath: string): Promise { + const cleanPath = chapterPath.replace(/^\//, ''); + const rawBody = await fetchApi(this.site + cleanPath).then(r => r.text()); + + 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 + cleanPath, + 'X-Requested-With': 'XMLHttpRequest', + }, + }, + ).then(async r => { + const res = await r.json(); + className = res.class; + return res.content; + }); + + const $ = parseHTML(body); + $('script').remove(); + $(`.${className} > *:not(br)`).after('
'); + const chapterText = $('.' + className).html() || ''; + + return chapterText.replace( + /class="advertisment"/g, + 'style="display:none;"', + ); + } + + async searchNovels(searchTerm: string): Promise { + 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 el = loadedCheerio(ele); + const novelName = el.find('div.title').text().trim(); + const novelUrl = ele.attribs.href; + const bareNovelCover = el.find('img').attr('src'); + const novelCover = bareNovelCover + ? this.site + bareNovelCover.replace(/^\//, '') + : defaultCover; + if (!novelUrl) return; + + novels.push({ + name: novelName, + cover: novelCover, + path: novelUrl.replace(/^\//, ''), + }); + }); + + return novels; + } + + filters = { + sort: { + label: 'Sort Results By', + value: 'popularity', + options: [ + { label: 'Title (A>Z)', value: 'title' }, + { label: 'Publication Date', value: '-time_created' }, + { label: 'Update Date (Newest)', value: '-time_updated' }, + { label: 'Year Release', value: '-year_of_release' }, + { label: 'Popularity', value: 'popularity' }, + ], + type: FilterTypes.Picker, + }, + translation: { + label: 'Translation Status', + value: [], + options: [ + { label: 'Ongoing', value: 'ongoing' }, + { label: 'Completed', value: 'completed' }, + { label: 'Paused', value: 'paused' }, + { label: 'Dropped', value: 'dropped' }, + { label: 'None', value: 'none' }, + ], + type: FilterTypes.CheckboxGroup, + }, + status: { + label: 'Status', + value: [], + options: [ + { label: 'Releasing', value: 'releasing' }, + { label: 'Completed', value: 'completed' }, + { label: 'Cancelled', value: 'cancelled' }, + { label: 'Not yet released', value: 'not+yet+released' }, + ], + type: FilterTypes.CheckboxGroup, + }, + novel_type: { + label: 'Type', + value: [], + options: [ + { label: 'Fan Fiction', value: '4' }, + { label: 'Light Novel', value: '1' }, + { label: 'Published Novel', value: '2' }, + { label: 'Web Novel', value: '3' }, + ], + type: FilterTypes.CheckboxGroup, + }, + genres: { + label: 'Genres', + value: [], + options: [ + { label: 'Thriller', value: '1' }, + { label: 'Supernatural', value: '2' }, + { label: 'Sports', value: '3' }, + { label: 'Slice of Life', value: '4' }, + { label: 'Sci-Fi', value: '5' }, + { label: 'Romance', value: '6' }, + { label: 'Psychological', value: '7' }, + { label: 'Mystery', value: '8' }, + { label: 'Mecha', value: '9' }, + { label: 'Horror', value: '10' }, + { label: 'Fantasy', value: '11' }, + { label: 'Ecchi', value: '12' }, + { label: 'Drama', value: '13' }, + { label: 'Comedy', value: '14' }, + { label: 'Adventure', value: '15' }, + { label: 'Action', value: '16' }, + { label: 'Adult', value: '17' }, + { label: 'Isekai', value: '18' }, + { label: 'Wuxia', value: '19' }, + { label: 'Shounen', value: '20' }, + { label: 'Yuri', value: '21' }, + { label: 'Shoujo', value: '22' }, + { label: 'Shoujo Ai', value: '23' }, + { label: 'Harem', value: '24' }, + { label: 'Seinen', value: '25' }, + { label: 'Tragedy', value: '26' }, + { label: 'Mature', value: '27' }, + { label: 'Martial Arts', value: '28' }, + { label: 'Gender Bender', value: '29' }, + { label: 'School Life', value: '30' }, + { label: 'Xuanhuan', value: '31' }, + { label: 'Yaoi', value: '32' }, + { label: 'Historical', value: '33' }, + ], + type: FilterTypes.CheckboxGroup, + }, + country: { + label: 'Country', + value: [], + options: [ + { label: 'China', value: '1' }, + { label: 'Japan', value: '2' }, + { label: 'Korea', value: '3' }, + { label: 'Other', value: '6' }, + ], + type: FilterTypes.CheckboxGroup, + }, + } satisfies Filters; +} + +export default new Mynovels(); diff --git a/public/static/src/en/mynovels/icon.png b/public/static/src/en/mynovels/icon.png new file mode 100644 index 000000000..1fb68f6ef Binary files /dev/null and b/public/static/src/en/mynovels/icon.png differ