From 436f20e652acea84dca63c2d3d1d6144a299043b Mon Sep 17 00:00:00 2001 From: 52191314 Date: Sun, 16 Aug 2026 14:36:06 +0700 Subject: [PATCH] feat(english): add Mynovels plugin --- plugins/english/mynovels.ts | 436 +++++++++++++++++++++++++ public/static/src/en/mynovels/icon.png | Bin 0 -> 5960 bytes 2 files changed, 436 insertions(+) create mode 100644 plugins/english/mynovels.ts create mode 100644 public/static/src/en/mynovels/icon.png 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 0000000000000000000000000000000000000000..1fb68f6efebbdfb418635c424fe7a4f942f6c125 GIT binary patch literal 5960 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