diff --git a/package-lock.json b/package-lock.json index 5df3743..7a55d44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7261,18 +7261,18 @@ } }, "node_modules/vite": { - "version": "6.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.2.tgz", - "integrity": "sha512-ZSvGOXKGceizRQIZSz7TGJ0pS3QLlVY/9hwxVh17W3re67je1RKYzFHivZ/t0tubU78Vkyb9WnHPENSBCzbckg==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.4.3", + "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", - "tinyglobby": "^0.2.12" + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" @@ -12421,18 +12421,18 @@ "dev": true }, "vite": { - "version": "6.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.2.tgz", - "integrity": "sha512-ZSvGOXKGceizRQIZSz7TGJ0pS3QLlVY/9hwxVh17W3re67je1RKYzFHivZ/t0tubU78Vkyb9WnHPENSBCzbckg==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, "requires": { "esbuild": "^0.25.0", - "fdir": "^6.4.3", + "fdir": "^6.4.4", "fsevents": "~2.3.3", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", - "tinyglobby": "^0.2.12" + "tinyglobby": "^0.2.13" }, "dependencies": { "fdir": { diff --git a/public/static/src/en/allnovelfull/icon.png b/public/static/multisrc/readnovelfull/anf.net/icon.png similarity index 100% rename from public/static/src/en/allnovelfull/icon.png rename to public/static/multisrc/readnovelfull/anf.net/icon.png diff --git a/public/static/src/en/freewebnovel/icon.png b/public/static/multisrc/readnovelfull/fwn.com/icon.png similarity index 100% rename from public/static/src/en/freewebnovel/icon.png rename to public/static/multisrc/readnovelfull/fwn.com/icon.png diff --git a/public/static/src/en/libread/icon.png b/public/static/multisrc/readnovelfull/libread/icon.png similarity index 100% rename from public/static/src/en/libread/icon.png rename to public/static/multisrc/readnovelfull/libread/icon.png diff --git a/scripts/multisrc/readnovelfull/filters/FWN.com.json b/scripts/multisrc/readnovelfull/filters/FWN.com.json new file mode 100644 index 0000000..8208840 --- /dev/null +++ b/scripts/multisrc/readnovelfull/filters/FWN.com.json @@ -0,0 +1,78 @@ +{ + "filters": { + "type": { + "type": "Picker", + "label": "Novel Type", + "value": "sort/most-popular", + "options": [ + { + "label": "All", + "value": "sort/latest-release" + }, + { + "label": "Chinese Novel", + "value": "sort/latest-release/chinese-novel/" + }, + { + "label": "Korean Novel", + "value": "sort/latest-release/korean-novel/" + }, + { + "label": "Japanese Novel", + "value": "sort/latest-release/japanese-novel/" + }, + { + "label": "English Novel", + "value": "sort/latest-release/english-novel/" + }, + { + "label": "Most Popular", + "value": "sort/most-popular" + } + ] + }, + "genres": { + "type": "Picker", + "label": "Genre", + "value": "", + "options": [ + { "label": "Action", "value": "genre/Action/" }, + { "label": "Adult", "value": "genre/Adult/" }, + { "label": "Adventure", "value": "genre/Adventure/" }, + { "label": "Comedy", "value": "genre/Comedy/" }, + { "label": "Drama", "value": "genre/Drama/" }, + { "label": "Eastern", "value": "genre/Eastern/" }, + { "label": "Ecchi", "value": "genre/Ecchi/" }, + { "label": "Fantasy", "value": "genre/Fantasy/" }, + { "label": "Game", "value": "genre/Game/" }, + { "label": "Gender Bender", "value": "genre/Gender+Bender/" }, + { "label": "Harem", "value": "genre/Harem/" }, + { "label": "Historical", "value": "genre/Historical/" }, + { "label": "Horror", "value": "genre/Horror/" }, + { "label": "Josei", "value": "genre/Josei/" }, + { "label": "Martial Arts", "value": "genre/Martial+Arts/" }, + { "label": "Mature", "value": "genre/Mature/" }, + { "label": "Mecha", "value": "genre/Mecha/" }, + { "label": "Mystery", "value": "genre/Mystery/" }, + { "label": "Psychological", "value": "genre/Psychological/" }, + { "label": "Reincarnation", "value": "genre/Reincarnation/" }, + { "label": "Romance", "value": "genre/Romance/" }, + { "label": "School Life", "value": "genre/School+Life/" }, + { "label": "Sci-fi", "value": "genre/Sci-fi/" }, + { "label": "Seinen", "value": "genre/Seinen/" }, + { "label": "Shoujo", "value": "genre/Shoujo/" }, + { "label": "Shounen Ai", "value": "genre/Shounen+Ai/" }, + { "label": "Shounen", "value": "genre/Shounen/" }, + { "label": "Slice of Life", "value": "genre/Slice+of+Life/" }, + { "label": "Smut", "value": "genre/Smut/" }, + { "label": "Sports", "value": "genre/Sports/" }, + { "label": "Supernatural", "value": "genre/Supernatural/" }, + { "label": "Tragedy", "value": "genre/Tragedy/" }, + { "label": "Wuxia", "value": "genre/Wuxia/" }, + { "label": "Xianxia", "value": "genre/Xianxia/" }, + { "label": "Xuanhuan", "value": "genre/Xuanhuan/" }, + { "label": "Yaoi", "value": "genre/Yaoi/" } + ] + } + } +} diff --git a/scripts/multisrc/readnovelfull/filters/anf.net.json b/scripts/multisrc/readnovelfull/filters/anf.net.json new file mode 100644 index 0000000..242e1d0 --- /dev/null +++ b/scripts/multisrc/readnovelfull/filters/anf.net.json @@ -0,0 +1,174 @@ +{ + "filters": { + "type": { + "type": "Picker", + "label": "Novel Listing", + "value": "most-popular", + "options": [ + { + "label": "Hot Novel", + "value": "hot-novel" + }, + { + "label": "Completed Novel", + "value": "completed-novel" + }, + { + "label": "Most Popular", + "value": "most-popular" + } + ] + }, + "genres": { + "type": "Picker", + "label": "Genre", + "value": "", + "options": [ + { + "label": "Shounen", + "value": "genre/Shounen" + }, + { + "label": "Harem", + "value": "genre/Harem" + }, + { + "label": "Comedy", + "value": "genre/Comedy" + }, + { + "label": "Martial Arts", + "value": "genre/Martial+Arts" + }, + { + "label": "School Life", + "value": "genre/School+Life" + }, + { + "label": "Mystery", + "value": "genre/Mystery" + }, + { + "label": "Shoujo", + "value": "genre/Shoujo" + }, + { + "label": "Romance", + "value": "genre/Romance" + }, + { + "label": "Sci-fi", + "value": "genre/Sci-fi" + }, + { + "label": "Gender Bender", + "value": "genre/Gender+Bender" + }, + { + "label": "Mature", + "value": "genre/Mature" + }, + { + "label": "Fantasy", + "value": "genre/Fantasy" + }, + { + "label": "Horror", + "value": "genre/Horror" + }, + { + "label": "Drama", + "value": "genre/Drama" + }, + { + "label": "Tragedy", + "value": "genre/Tragedy" + }, + { + "label": "Supernatural", + "value": "genre/Supernatural" + }, + { + "label": "Ecchi", + "value": "genre/Ecchi" + }, + { + "label": "Xuanhuan", + "value": "genre/Xuanhuan" + }, + { + "label": "Adventure", + "value": "genre/Adventure" + }, + { + "label": "Action", + "value": "genre/Action" + }, + { + "label": "Psychological", + "value": "genre/Psychological" + }, + { + "label": "Xianxia", + "value": "genre/Xianxia" + }, + { + "label": "Wuxia", + "value": "genre/Wuxia" + }, + { + "label": "Historical", + "value": "genre/Historical" + }, + { + "label": "Slice of Life", + "value": "genre/Slice+of+Life" + }, + { + "label": "Seinen", + "value": "genre/Seinen" + }, + { + "label": "Lolicon", + "value": "genre/Lolicon" + }, + { + "label": "Adult", + "value": "genre/Adult" + }, + { + "label": "Josei", + "value": "genre/Josei" + }, + { + "label": "Sports", + "value": "genre/Sports" + }, + { + "label": "Smut", + "value": "genre/Smut" + }, + { + "label": "Mecha", + "value": "genre/Mecha" + }, + { + "label": "Yaoi", + "value": "genre/Yaoi" + }, + { + "label": "Shounen Ai", + "value": "genre/Shounen+Ai" + }, + { + "label": "Magical Realism", + "value": "genre/Magical+Realism" + }, + { + "label": "Video Games", + "value": "genre/Video+Games" + } + ] + } + } +} \ No newline at end of file diff --git a/scripts/multisrc/readnovelfull/filters/libread.json b/scripts/multisrc/readnovelfull/filters/libread.json new file mode 100644 index 0000000..8208840 --- /dev/null +++ b/scripts/multisrc/readnovelfull/filters/libread.json @@ -0,0 +1,78 @@ +{ + "filters": { + "type": { + "type": "Picker", + "label": "Novel Type", + "value": "sort/most-popular", + "options": [ + { + "label": "All", + "value": "sort/latest-release" + }, + { + "label": "Chinese Novel", + "value": "sort/latest-release/chinese-novel/" + }, + { + "label": "Korean Novel", + "value": "sort/latest-release/korean-novel/" + }, + { + "label": "Japanese Novel", + "value": "sort/latest-release/japanese-novel/" + }, + { + "label": "English Novel", + "value": "sort/latest-release/english-novel/" + }, + { + "label": "Most Popular", + "value": "sort/most-popular" + } + ] + }, + "genres": { + "type": "Picker", + "label": "Genre", + "value": "", + "options": [ + { "label": "Action", "value": "genre/Action/" }, + { "label": "Adult", "value": "genre/Adult/" }, + { "label": "Adventure", "value": "genre/Adventure/" }, + { "label": "Comedy", "value": "genre/Comedy/" }, + { "label": "Drama", "value": "genre/Drama/" }, + { "label": "Eastern", "value": "genre/Eastern/" }, + { "label": "Ecchi", "value": "genre/Ecchi/" }, + { "label": "Fantasy", "value": "genre/Fantasy/" }, + { "label": "Game", "value": "genre/Game/" }, + { "label": "Gender Bender", "value": "genre/Gender+Bender/" }, + { "label": "Harem", "value": "genre/Harem/" }, + { "label": "Historical", "value": "genre/Historical/" }, + { "label": "Horror", "value": "genre/Horror/" }, + { "label": "Josei", "value": "genre/Josei/" }, + { "label": "Martial Arts", "value": "genre/Martial+Arts/" }, + { "label": "Mature", "value": "genre/Mature/" }, + { "label": "Mecha", "value": "genre/Mecha/" }, + { "label": "Mystery", "value": "genre/Mystery/" }, + { "label": "Psychological", "value": "genre/Psychological/" }, + { "label": "Reincarnation", "value": "genre/Reincarnation/" }, + { "label": "Romance", "value": "genre/Romance/" }, + { "label": "School Life", "value": "genre/School+Life/" }, + { "label": "Sci-fi", "value": "genre/Sci-fi/" }, + { "label": "Seinen", "value": "genre/Seinen/" }, + { "label": "Shoujo", "value": "genre/Shoujo/" }, + { "label": "Shounen Ai", "value": "genre/Shounen+Ai/" }, + { "label": "Shounen", "value": "genre/Shounen/" }, + { "label": "Slice of Life", "value": "genre/Slice+of+Life/" }, + { "label": "Smut", "value": "genre/Smut/" }, + { "label": "Sports", "value": "genre/Sports/" }, + { "label": "Supernatural", "value": "genre/Supernatural/" }, + { "label": "Tragedy", "value": "genre/Tragedy/" }, + { "label": "Wuxia", "value": "genre/Wuxia/" }, + { "label": "Xianxia", "value": "genre/Xianxia/" }, + { "label": "Xuanhuan", "value": "genre/Xuanhuan/" }, + { "label": "Yaoi", "value": "genre/Yaoi/" } + ] + } + } +} diff --git a/scripts/multisrc/readnovelfull/sources.json b/scripts/multisrc/readnovelfull/sources.json index 5340bf0..b06a5b0 100644 --- a/scripts/multisrc/readnovelfull/sources.json +++ b/scripts/multisrc/readnovelfull/sources.json @@ -51,5 +51,41 @@ "searchPage": "search", "chapterListing": "ajax-chapter-option" } + }, + { + "id": "libread", + "sourceSite": "https://libread.com/", + "sourceName": "Lib Read", + "options": { + "latestPage": "sort/latest-novels", + "searchPage": "search", + "searchKey": "searchkey", + "postSearch": true, + "noAjax": true, + "noPages": ["sort/most-popular"] + } + }, + { + "id": "FWN.com", + "sourceSite": "https://freewebnovel.com/", + "sourceName": "Free Web Novel", + "options": { + "latestPage": "sort/latest-novels", + "searchPage": "search", + "searchKey": "searchkey", + "postSearch": true, + "noAjax": true, + "noPages": ["sort/most-popular"] + } + }, + { + "id": "anf.net", + "sourceSite": "https://novgo.net/", + "sourceName": "AllNovelFull", + "options": { + "latestPage": "latest-release-novel", + "searchPage": "search", + "chapterListing": "ajax-chapter-option" + } } ] diff --git a/scripts/multisrc/readnovelfull/template.ts b/scripts/multisrc/readnovelfull/template.ts index cf66eaa..84d2619 100644 --- a/scripts/multisrc/readnovelfull/template.ts +++ b/scripts/multisrc/readnovelfull/template.ts @@ -1,4 +1,4 @@ -import { CheerioAPI, load } from 'cheerio'; +import { Parser } from 'htmlparser2'; import { fetchApi } from '@libs/fetch'; import { Plugin } from '@typings/plugin'; import { NovelStatus } from '@libs/novelStatus'; @@ -18,6 +18,10 @@ type ReadNovelFullOptions = { genreKey?: string; langParam?: string; urlLangCode?: string; + searchKey?: string; + postSearch?: boolean; + noAjax?: boolean; + noPages?: string[]; }; export type ReadNovelFullMetadata = { @@ -43,56 +47,93 @@ class ReadNovelFullPlugin implements Plugin.PluginBase { this.icon = `multisrc/readnovelfull/${metadata.id.toLowerCase()}/icon.png`; this.site = metadata.sourceSite; const versionIncrements = metadata.options?.versionIncrements || 0; - this.version = `2.0.${1 + versionIncrements}`; + this.version = `2.1.${0 + versionIncrements}`; this.options = metadata.options; this.filters = metadata.filters; } - async getCheerio(url: string, search: boolean): Promise { - const r = await fetchApi(url); - if (!r.ok && search != true) - throw new Error( - 'Could not reach site (' + r.status + ') try to open in webview.', - ); - const $ = load(await r.text()); + lastSearch: number | null = null; + searchInterval = 3400; - return $; + async sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); } - parseNovels($: CheerioAPI): Plugin.NovelItem[] { - const baseUrl = this.site; + parseNovels(html: string) { + const novels: Plugin.NovelItem[] = []; + let tempNovel: Partial = {}; + let depth: number; - const novels = $('.archive .list-novel .row, .archive .list-truyen .row') - .toArray() - .map(el => { - const $el = $(el); + const stateStack: ParsingState[] = [ParsingState.Idle]; + const currentState = () => stateStack[stateStack.length - 1]; + const pushState = (state: ParsingState) => stateStack.push(state); + const popState = () => + stateStack.length > 1 ? stateStack.pop() : currentState(); - const novelName = $el.find('h3').text()?.trim(); - const rawHref = $el.find('a').attr('href'); - const rawCover = - $el.find('img').attr('src') ?? - $el.find('img').attr('data-cfsrc') ?? - $el.find('img').attr('data-src'); - - if (!novelName || !rawHref) { - return null; + const parser = new Parser({ + onopentag: (name, attribs) => { + const state = currentState(); + if ( + attribs.class?.includes('archive') || + attribs.class === 'col-content' + ) { + pushState(ParsingState.NovelList); + depth = 0; } - const novelUrlObject = new URL(rawHref, baseUrl); - const novelPath = novelUrlObject.pathname.slice(1); + if ( + state !== ParsingState.NovelList && + state !== ParsingState.NovelName + ) + return; - let novelCover: string | undefined; - if (rawCover) { - novelCover = new URL(rawCover, baseUrl).href; + switch (name) { + case 'img': + const cover = attribs['data-src'] || attribs.src; + if (cover) { + tempNovel.cover = new URL(cover, this.site).href; + } + break; + case 'h3': + if (state === ParsingState.NovelList) { + pushState(ParsingState.NovelName); + } + break; + case 'a': + if (state === ParsingState.NovelName) { + const href = attribs.href; + if (href) { + tempNovel.path = new URL(href, this.site).pathname.substring(1); + tempNovel.name = attribs.title; + } + } + break; + case 'div': + depth++; + break; + default: + return; } + }, - return { - name: novelName, - path: novelPath, - cover: novelCover, - }; - }) - .filter(Boolean); + onclosetag: name => { + const state = currentState(); + if (name === 'a' && state === ParsingState.NovelName) { + if (tempNovel.name && tempNovel.path) { + novels.push({ ...tempNovel } as Plugin.NovelItem); + } + tempNovel = {}; + popState(); + } + if (name === 'div' && state === ParsingState.NovelList) { + depth--; + if (depth < 0) popState(); + } + }, + }); + + parser.write(html); + parser.end(); return novels; } @@ -101,174 +142,606 @@ class ReadNovelFullPlugin implements Plugin.PluginBase { pageNo: number, { filters, showLatestNovels }: Plugin.PopularNovelsOptions, ): Promise { - let url = ''; - const pageParam = this.options.pageParam || 'page'; + const { + pageParam = 'page', + novelListing, + typeParam = 'type', + latestPage, + genreParam = 'category_novel', + genreKey = 'id', + langParam, + urlLangCode, + noPages = [], + } = this.options; - if (this.options.novelListing) { + // Skip Pagination for FWN & LR + if ( + pageNo !== 1 && + !showLatestNovels && + !filters.genres.value.length && + noPages.length > 0 && + noPages.includes(filters.type.value) + ) { + return []; + } + + let url = ''; + + if (novelListing) { // URL structure with parameters const params = new URLSearchParams(); - const typeParam = this.options.typeParam || 'type'; // Default to 'type' if not specified if (showLatestNovels) { - params.append(typeParam, this.options.latestPage); + params.append(typeParam, latestPage); } else if (filters.genres.value.length) { - params.append(typeParam, this.options.genreParam || 'category_novel'); // same below - const genreKey = this.options.genreKey || 'id'; // Default to 'id' if not specified + params.append(typeParam, genreParam); params.append(genreKey, filters.genres.value); } else { params.append(typeParam, filters.type.value); } // Add language parameter if specified - if (this.options.langParam && this.options.urlLangCode) { - params.append(this.options.langParam, this.options.urlLangCode); + if (langParam && urlLangCode) { + params.append(langParam, urlLangCode); } params.append(pageParam, pageNo.toString()); - - url = `${this.site}${this.options.novelListing}?${params.toString()}`; + url = `${this.site}${novelListing}?${params.toString()}`; } else { // URL structure with path segments - let basePage = ''; - const params = new URLSearchParams({ - [pageParam]: pageNo.toString(), - }); + const basePage = showLatestNovels + ? latestPage + : filters.genres.value.length + ? filters.genres.value + : filters.type.value; - if (showLatestNovels) { - basePage = this.options.latestPage; - } else if (filters.genres.value.length) { - basePage = filters.genres.value; - } else { - basePage = filters.type.value; - } - - url = `${this.site}${basePage}?${params.toString()}`; + url = `${this.site}${basePage}?${pageParam}=${pageNo}`; } - const $ = await this.getCheerio(url, false); - return this.parseNovels($); + const result = await fetchApi(url); + if (!result.ok) { + throw new Error( + `Could not reach site (${result.status}: ${result.statusText}) try to open in webview.`, + ); + } + const html = await result.text(); + return this.parseNovels(html); } async parseNovel(novelPath: string): Promise { - let $ = await this.getCheerio(this.site + novelPath, false); - const baseUrl = this.site; + const url = this.site + novelPath; + const result = await fetchApi(url); + const body = await result.text(); const novel: Partial = { path: novelPath, + chapters: [], }; + const summaryParts: string[] = []; + const statusParts: string[] = []; + const authorParts: string[] = []; + const genreArray: string[] = []; + const infoParts: string[] = []; + const chapters: Plugin.ChapterItem[] = []; + let novelId: string | null = null; + let tempChapter: Partial = {}; + let i = 0; + let depth: number; - novel.name = $('h3.title').first().text().trim(); - const coverUrl = - $('.book img').attr('src') ?? - $('.book img').attr('data-cfsrc') ?? - $('.book img').attr('data-src') ?? - $('.book noscript img').attr('src') ?? - $('meta[name="image"]').attr('content'); + const stateStack: ParsingState[] = [ParsingState.Idle]; + const currentState = () => stateStack[stateStack.length - 1]; + const pushState = (state: ParsingState) => stateStack.push(state); + const popState = () => + stateStack.length > 1 ? stateStack.pop() : currentState(); - if (coverUrl) { - novel.cover = new URL(coverUrl, baseUrl).href; - } + const parser = new Parser({ + onopentag: (name, attribs) => { + const state = currentState(); + switch (name) { + case 'div': + switch (attribs.class) { + case 'books': + case 'm-imgtxt': + pushState(ParsingState.Cover); + return; + case 'inner': + case 'desc-text': + if (state === ParsingState.Cover) popState(); + pushState(ParsingState.Summary); + break; + case 'info': + pushState(ParsingState.Info); + depth = 0; + break; + } + if (!this.options.noAjax && attribs.id === 'rating') { + novelId = attribs['data-novel-id']; + } + if (state === ParsingState.Info) depth++; + break; + case 'img': + if (state === ParsingState.Cover) { + const cover = + attribs.src ?? attribs['data-cfsrc'] ?? attribs['data-src']; + const name = attribs.title; + if (cover) { + novel.cover = new URL(cover, this.site).href; + } + if (name) { + novel.name = name; + } else { + popState(); + } + } + break; + case 'h3': + if (state === ParsingState.Cover) { + pushState(ParsingState.NovelName); + } + break; + case 'span': + if (state === ParsingState.Cover && attribs.title) { + const newState = { + 'Genre': ParsingState.Genres, + 'Author': ParsingState.Author, + 'Status': ParsingState.Status, + }[attribs.title]; - $('ul.info.info-meta li, .info div').each(function () { - const detailText = $(this).text().split(':'); - const detailName = detailText[0].trim().toLowerCase(); - const detail = detailText[1] - ?.split(',') - .map(g => g.trim()) - .join(', '); + if (newState) pushState(newState); + } + break; + case 'br': + if (state === ParsingState.Summary) { + summaryParts.push('\n'); + } + break; + case 'ul': + if (attribs.class?.includes('info-meta')) { + pushState(ParsingState.Info); + } + if (this.options.noAjax && attribs.id === 'idData') { + pushState(ParsingState.ChapterList); + } + break; + case 'a': + if (state === ParsingState.ChapterList) { + i++; + const href = attribs.href; + pushState(ParsingState.Chapter); - switch (detailName) { - case 'author': - novel.author = detail; - break; - case 'genre': - novel.genres = detail; - break; - case 'status': - const map: Record = { - completed: NovelStatus.Completed, - ongoing: NovelStatus.Ongoing, - hiatus: NovelStatus.OnHiatus, - }; - novel.status = map[detail.toLowerCase()] ?? NovelStatus.Unknown; - } + tempChapter.name = attribs.title || `Chapter ${i}`; + tempChapter.releaseTime = null; + tempChapter.chapterNumber = i; + tempChapter.path = + href?.substring(1) || + novelPath.replace('.html', `/chapter-${i}.html`); + } + break; + } + }, + + ontext: data => { + const text = data.trim(); + if (!text) return; + + switch (currentState()) { + case ParsingState.NovelName: + novel.name = (novel.name || '') + text; + break; + case ParsingState.Summary: + summaryParts.push(data); + break; + case ParsingState.Info: + infoParts.push(text); + break; + case ParsingState.Genres: + genreArray.push(data); + break; + case ParsingState.Author: + authorParts.push(data); + break; + case ParsingState.Status: + statusParts.push(text); + break; + } + }, + + onclosetag: name => { + const state = currentState(); + switch (name) { + case 'div': + switch (state) { + case ParsingState.Info: + depth--; + infoParts.push('\n'); + if (depth < 0) { + popState(); + } + break; + case ParsingState.Genres: + case ParsingState.Author: + case ParsingState.Status: + case ParsingState.Summary: + popState(); + break; + } + break; + case 'h3': + if (state === ParsingState.NovelName) { + popState(); + } + break; + case 'a': + if (state === ParsingState.Chapter) { + if (tempChapter.name && tempChapter.path) { + chapters.push({ ...tempChapter } as Plugin.ChapterItem); + } + tempChapter = {}; + popState(); + } + break; + case 'li': + if (state === ParsingState.Info) { + infoParts.push('\n'); + } + break; + case 'ul': + switch (state) { + case ParsingState.Info: + case ParsingState.ChapterList: + popState(); + break; + } + break; + default: + return; + } + }, + + onend: () => { + if (infoParts.length) { + infoParts + .join('') + .split('\n') + .map(line => line.trim()) + .filter(line => line.includes(':')) + .forEach(line => { + const parts = line.split(':'); + const detailName = parts[0].trim().toLowerCase(); + const detail = parts[1] + .split(',') + .map(g => g.trim()) + .join(', '); + + switch (detailName) { + case 'author': + novel.author = detail; + break; + case 'genre': + novel.genres = detail; + break; + case 'status': + const map: Record = { + ongoing: NovelStatus.Ongoing, + hiatus: NovelStatus.OnHiatus, + dropped: NovelStatus.Cancelled, + cancelled: NovelStatus.Cancelled, + completed: NovelStatus.Completed, + }; + novel.status = + map[detail.toLowerCase()] ?? NovelStatus.Unknown; + break; + default: + return; + } + }); + + if (!novelId) { + const idMatch = novelPath.match(/\d+/); + novelId = idMatch ? idMatch[0] : null; + } + } else { + novel.genres = genreArray.join('').trim(); + novel.author = authorParts.join('').trim(); + novel.status = statusParts + .join('') + .toLowerCase() + .replace(/\b\w/g, char => char.toUpperCase()); + } + novel.summary = summaryParts.join('\n\n').trim(); + }, }); - const summaryDiv = $('.desc-text'); - const pTagSummary = summaryDiv.find('p'); - novel.summary = - pTagSummary.length > 0 - ? pTagSummary - .map((_, el) => $(el).text().trim()) - .get() - .join('\n\n') - : summaryDiv.text().trim(); // --- Else (no

tags, assume
) --- + parser.write(body); + parser.end(); - const chapters: Plugin.ChapterItem[] = []; - - const dataNovelId = $('#rating').attr('data-novel-id'); - const idMatch = novelPath.match(/\d+/); - const novelId = dataNovelId || (idMatch ? idMatch[0] : null); - - if (novelId) { + if (this.options.noAjax && chapters.length > 0) { + novel.chapters = chapters; + } else if (novelId !== null) { const chapterListing = this.options.chapterListing || 'ajax/chapter-archive'; - const ajaxParam = this.options.chapterParam || 'novelId'; - const params = new URLSearchParams({ - [ajaxParam]: novelId.toString(), - }); - + const params = new URLSearchParams({ [ajaxParam]: novelId }); const chaptersUrl = `${this.site}${chapterListing}?${params.toString()}`; - $ = await this.getCheerio(chaptersUrl, false); + const ajaxResult = await fetchApi(chaptersUrl); + if (!ajaxResult.ok) { + console.error(`Failed to fetch chapters: ${ajaxResult.status}`); + novel.chapters = []; + } else { + const ajaxBody = await ajaxResult.text(); + const ajaxChapters: Plugin.ChapterItem[] = []; + let tempAjaxChapter: Partial = {}; - const listing = $('.panel-body li a, select option'); + const ajaxParser = new Parser({ + onopentag: (name, attribs) => { + let chapterHref: string | undefined; + let initialName: string | undefined; - listing.each((_, el) => { - const $el = $(el); - const chapterName = $el.attr('title') || $el.text() || ''; - const chapterHref = $el.attr('href') || $el.attr('value') || ''; + if (name === 'a' && attribs.href) { + chapterHref = attribs.href; + initialName = attribs.title || ''; + pushState(ParsingState.Chapter); + } else if (name === 'option' && attribs.value) { + chapterHref = attribs.value; + initialName = ''; + pushState(ParsingState.Chapter); + } - const chapterUrlObject = new URL(chapterHref, this.site); - const chapterUrl = chapterUrlObject.pathname.slice(1); + if (chapterHref !== undefined) { + const href = new URL(chapterHref, this.site); + tempAjaxChapter.path = href.pathname.substring(1); + tempAjaxChapter.name = initialName; + } + }, - chapters.push({ - name: chapterName, - path: chapterUrl, - releaseTime: null, + ontext: data => { + const text = data.trim(); + if ( + currentState() === ParsingState.Chapter && + !tempAjaxChapter.name && + text + ) { + tempAjaxChapter.name += text; + } + }, + + onclosetag: name => { + if ( + (name === 'a' || name === 'option') && + currentState() === ParsingState.Chapter + ) { + if (tempAjaxChapter.name && tempAjaxChapter.path) { + tempAjaxChapter.name = tempAjaxChapter.name.trim(); + tempAjaxChapter.releaseTime = null; + ajaxChapters.push({ + ...tempAjaxChapter, + } as Plugin.ChapterItem); + } + tempAjaxChapter = {}; + popState(); + } + }, }); - }); + + ajaxParser.write(ajaxBody); + ajaxParser.end(); + novel.chapters = ajaxChapters; + } } - novel.chapters = chapters; return novel as Plugin.SourceNovel; } async parseChapter(chapterPath: string): Promise { - const $ = await this.getCheerio(this.site + chapterPath, false); - $('div.ads, div.unlock-buttons').remove(); - return $('#chr-content, #chapter-content').html()! || ''; + const response = await fetchApi(this.site + chapterPath); + const html = await response.text(); + + let depth: number; + let depthHide: number; + const chapterHtml: string[] = []; + let skipClosingTag = false; + let currentTagToSkip = ''; + + const stateStack: ParsingState[] = [ParsingState.Idle]; + const currentState = () => stateStack[stateStack.length - 1]; + const pushState = (state: ParsingState) => stateStack.push(state); + const popState = () => + stateStack.length > 1 ? stateStack.pop() : currentState(); + + type EscapeChar = '&' | '<' | '>' | '"' | "'" | ' '; + const escapeRegex = /[&<>"' ]/g; + const escapeMap: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + ' ': ' ', + }; + const escapeHtml = (text: string): string => + text.replace(escapeRegex, char => escapeMap[char as EscapeChar]); + + const parser = new Parser({ + onopentag(name, attribs) { + const state = currentState(); + const attrib = attribs.class?.trim(); + switch (state) { + case ParsingState.Idle: + if ( + attrib === 'txt' || + attribs.id === 'chr-content' || + attribs.id === 'chapter-content' + ) { + pushState(ParsingState.Chapter); + depth = 0; + } + break; + case ParsingState.Chapter: + if (name === 'sub') { + pushState(ParsingState.Hidden); + } else if (name === 'div') { + depth++; + if ( + attrib?.includes('unlock-buttons') || + attrib?.includes('ads') + ) { + pushState(ParsingState.Hidden); + depthHide = 0; + } + } + break; + case ParsingState.Hidden: + if (name === 'sub') { + // Allow nesting of hidden states if a sub is inside a div + pushState(ParsingState.Hidden); + } else if (name === 'div') { + depthHide++; + } + break; + default: + return; + } + + if (currentState() === ParsingState.Chapter) { + const attrKeys = Object.keys(attribs); + + if (attrKeys.length === 0) { + chapterHtml.push(`<${name}>`); + } else if (attrKeys.every(key => attribs[key].trim() === '')) { + // Handle tags with empty attributes as text content + // eg: novel/rising-up-from-a-nobleman-to-intergalactic-warlord/chapter-184 + skipClosingTag = true; + currentTagToSkip = name; + const uppercaseName = name.replace(/\b\w/g, char => + char.toUpperCase(), + ); + chapterHtml.push( + escapeHtml(`<${uppercaseName} ${attrKeys.join(' ')}>`), + ); + } else { + // Normal tag with attributes + const attrString = attrKeys + .map(key => ` ${key}="${attribs[key].replace(/"/g, '"')}"`) + .join(''); + chapterHtml.push(`<${name}${attrString}>`); + } + } + }, + + ontext(text) { + if (currentState() === ParsingState.Chapter) { + chapterHtml.push(escapeHtml(text)); + } + }, + + onclosetag(name) { + const state = currentState(); + + if (state === ParsingState.Hidden) { + if (name === 'sub') { + popState(); + } else if (name === 'div') { + depthHide--; + if (depthHide < 0) { + popState(); + } + } + } + + if (state !== ParsingState.Chapter) { + return; + } + + if (!parser['isVoidElement'](name)) { + if (skipClosingTag && name === currentTagToSkip) { + skipClosingTag = false; + currentTagToSkip = ''; + } else { + chapterHtml.push(``); + } + } + + if (name === 'div') { + depth--; + if (depth < 0) { + pushState(ParsingState.Stopped); + } + } + }, + }); + + parser.write(html); + parser.end(); + + return chapterHtml.join(''); } async searchNovels( searchTerm: string, page: number, ): Promise { - const pageParam = this.options.pageParam || 'page'; - - const params = new URLSearchParams({ - keyword: searchTerm, - [pageParam]: page.toString(), - }); - - if (this.options.langParam) { - params.append(this.options.langParam, this.options.urlLangCode!); + const now = Date.now(); + if (this.lastSearch && now - this.lastSearch <= this.searchInterval) { + await this.sleep(this.searchInterval); } - const url = `${this.site}${this.options.searchPage}?${params.toString()}`; - const $ = await this.getCheerio(url, true); + const { + pageParam = 'page', + searchKey = 'keyword', + postSearch, + langParam, + urlLangCode, + searchPage, + } = this.options; - return this.parseNovels($); + const params = new URLSearchParams({ + [searchKey]: searchTerm, + ...(langParam && urlLangCode && { [langParam]: urlLangCode }), + ...(!postSearch && { [pageParam]: page.toString() }), + }); + + const url = `${this.site}${searchPage}${!postSearch ? `?${params.toString()}` : ''}`; + + const fetchOptions: RequestInit | undefined = postSearch + ? { + method: 'POST', + body: params.toString(), + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + } + : undefined; + + const result = await fetchApi(url, fetchOptions); + this.lastSearch = Date.now(); + + if (!result.ok) { + throw new Error( + `Could not reach site ('${result.status}') try to open in webview.`, + ); + } + + const html = await result.text(); + + // Check for alert error messages, ported over from cheerio TODO: confirm behaviour + const alertText = html.match(/alert\((.*?)\)/)?.[1] || ''; + if (alertText) throw new Error(alertText); + + return this.parseNovels(html); } } + +enum ParsingState { + Idle, + Info, + Cover, + Author, + Genres, + Status, + Hidden, + Summary, + Stopped, + Chapter, + ChapterList, + NovelName, + NovelList, +} diff --git a/src/plugins/english/LibRead.ts b/src/plugins/english/LibRead.ts deleted file mode 100644 index 47e919d..0000000 --- a/src/plugins/english/LibRead.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { Plugin } from '@typings/plugin'; -import { fetchApi } from '@libs/fetch'; -import { CheerioAPI, load as parseHTML } from 'cheerio'; -import { Filters, FilterTypes } from '@libs/filterInputs'; - -class LibReadPlugin implements Plugin.PluginBase { - id = 'libread'; - name = 'Lib Read'; - icon = 'src/en/libread/icon.png'; - site = 'https://libread.com'; - version = '1.1.0'; - - async getCheerio(url: string): Promise { - const r = await fetchApi(url); - if (!r.ok) - throw new Error( - `Could not reach site (${r.status}: ${r.statusText}) try to open in webview.`, - ); - return parseHTML(await r.text()); - } - - parseNovels(loadedCheerio: CheerioAPI): Plugin.NovelItem[] { - return loadedCheerio('.li-row') - .map((index, element) => ({ - name: loadedCheerio(element).find('.tit').text() || '', - cover: loadedCheerio(element).find('img').attr('src'), - path: loadedCheerio(element).find('h3 > a').attr('href') || '', - })) - .get() - .filter(novel => novel.name && novel.path); - } - - async popularNovels( - page: number, - { - showLatestNovels, - filters, - }: Plugin.PopularNovelsOptions, - ): Promise { - let url = this.site; - if (showLatestNovels) url += '/sort/latest-novels/'; - else { - if ( - filters && - filters.type_genre && - filters.type_genre.value !== 'all' && - filters.type_genre.value !== 'genre' - ) - url += filters.type_genre.value; - else { - url += '/most-popular/'; - if (page != 1) return []; - page = 0; - } - } - url += page; - - const $ = await this.getCheerio(url); - return this.parseNovels($); - } - - async parseNovel(novelPath: string): Promise { - const loadedCheerio = await this.getCheerio(this.site + novelPath); - - const novel: Plugin.SourceNovel = { - path: novelPath, - name: loadedCheerio('h1.tit').text(), - cover: loadedCheerio('.pic > img').attr('src'), - summary: loadedCheerio('.inner').text().trim(), - }; - - novel.genres = loadedCheerio('[title=Genre]') - .next() - .text() - .replace(/[\t\n]/g, ''); - - novel.author = loadedCheerio('[title=Author]') - .next() - .text() - .replace(/[\t\n]/g, ''); - - novel.status = loadedCheerio('[title=Status]') - .next() - .text() - .replace(/[\t\n]/g, ''); - - novel.genres = loadedCheerio('[title=Genre]') - .next() - .text() - .trim() - .replace(/[\t\n]/g, ',') - .replace(/, /g, ','); - - const chapters: Plugin.ChapterItem[] = loadedCheerio('#idData > li > a') - .map((chapterIndex, element) => ({ - name: - loadedCheerio(element).attr('title') || - 'Chapter ' + (chapterIndex + 1), - path: - loadedCheerio(element).attr('href') || - novelPath.replace( - '.html', - '/chapter-' + (chapterIndex + 1) + '.html', - ), - releaseTime: null, - chapterNumber: chapterIndex + 1, - })) - .get(); - - novel.chapters = chapters; - return novel; - } - - async parseChapter(chapterPath: string): Promise { - const loadedCheerio = await this.getCheerio(this.site + chapterPath); - - // remove freewebnovel signature - if (loadedCheerio('style').text().includes('p:nth-last-child(1)')) - loadedCheerio('div.txt').find('p:last-child').remove(); - - const chapterText = loadedCheerio('div.txt').html() || ''; - return chapterText; - } - - async searchNovels(searchTerm: string): Promise { - const r = await fetchApi(this.site + '/search/', { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Referer: this.site, - Origin: this.site, - }, - method: 'POST', - body: new URLSearchParams({ searchkey: searchTerm }).toString(), - }); - if (!r.ok) - throw new Error( - 'Could not reach site (' + r.status + ') try to open in webview.', - ); - - const loadedCheerio = parseHTML(await r.text()); - const alertText = - loadedCheerio('script') - .text() - .match(/alert\((.*?)\)/)?.[1] || ''; - if (alertText) throw new Error(alertText); - - return this.parseNovels(loadedCheerio); - } - - filters = { - type_genre: { - type: FilterTypes.Picker, - label: 'Novel Type/Genre', - value: 'all', - options: [ - { label: 'Tous', value: 'all' }, - { label: '═══NOVEL TYPES═══', value: '/sort/latest-release/' }, - { - label: 'Chinese Novel', - value: '/sort/latest-release/chinese-novel/', - }, - { label: 'Korean Novel', value: '/sort/latest-release/korean-novel/' }, - { - label: 'Japanese Novel', - value: '/sort/latest-release/japanese-novel/', - }, - { - label: 'English Novel', - value: '/sort/latest-release/english-novel/', - }, - { label: '═══GENRES═══', value: 'genre' }, - { label: 'Action', value: '/genre/Action/' }, - { label: 'Adult', value: '/genre/Adult/' }, - { label: 'Adventure', value: '/genre/Adventure/' }, - { label: 'Comedy', value: '/genre/Comedy/' }, - { label: 'Drama', value: '/genre/Drama/' }, - { label: 'Eastern', value: '/genre/Eastern/' }, - { label: 'Ecchi', value: '/genre/Ecchi/' }, - { label: 'Fantasy', value: '/genre/Fantasy/' }, - { label: 'Game', value: '/genre/Game/' }, - { label: 'Gender Bender', value: '/genre/Gender+Bender/' }, - { label: 'Harem', value: '/genre/Harem/' }, - { label: 'Historical', value: '/genre/Historical/' }, - { label: 'Horror', value: '/genre/Horror/' }, - { label: 'Josei', value: '/genre/Josei/' }, - { label: 'Martial Arts', value: '/genre/Martial+Arts/' }, - { label: 'Mature', value: '/genre/Mature/' }, - { label: 'Mecha', value: '/genre/Mecha/' }, - { label: 'Mystery', value: '/genre/Mystery/' }, - { label: 'Psychological', value: '/genre/Psychological/' }, - { label: 'Reincarnation', value: '/genre/Reincarnation/' }, - { label: 'Romance', value: '/genre/Romance/' }, - { label: 'School Life', value: '/genre/School+Life/' }, - { label: 'Sci-fi', value: '/genre/Sci-fi/' }, - { label: 'Seinen', value: '/genre/Seinen/' }, - { label: 'Shoujo', value: '/genre/Shoujo/' }, - { label: 'Shounen Ai', value: '/genre/Shounen+Ai/' }, - { label: 'Shounen', value: '/genre/Shounen/' }, - { label: 'Slice of Life', value: '/genre/Slice+of+Life/' }, - { label: 'Smut', value: '/genre/Smut/' }, - { label: 'Sports', value: '/genre/Sports/' }, - { label: 'Supernatural', value: '/genre/Supernatural/' }, - { label: 'Tragedy', value: '/genre/Tragedy/' }, - { label: 'Wuxia', value: '/genre/Wuxia/' }, - { label: 'Xianxia', value: '/genre/Xianxia/' }, - { label: 'Xuanhuan', value: '/genre/Xuanhuan/' }, - { label: 'Yaoi', value: '/genre/Yaoi/' }, - ], - }, - } satisfies Filters; -} - -export default new LibReadPlugin(); diff --git a/src/plugins/english/allnovelfull.ts b/src/plugins/english/allnovelfull.ts deleted file mode 100644 index ec524cc..0000000 --- a/src/plugins/english/allnovelfull.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { CheerioAPI, load as parseHTML } from 'cheerio'; -import { fetchApi } from '@libs/fetch'; -import { FilterTypes, Filters } from '@libs/filterInputs'; -import { Plugin } from '@typings/plugin'; -import { isUrlAbsolute } from '@libs/isAbsoluteUrl'; - -class AllNovelFullPlugin implements Plugin.PluginBase { - id = 'anf.net'; - name = 'AllNovelFull'; - icon = 'src/en/allnovelfull/icon.png'; - site = 'https://allnovelfull.net'; - version = '1.0.2'; - - parseNovels(loadedCheerio: CheerioAPI) { - const novels: Plugin.NovelItem[] = []; - - loadedCheerio('.col-truyen-main .list-truyen .row').each((i, el) => { - const novelUrl = loadedCheerio(el) - .find('h3.truyen-title > a') - .attr('href'); - if (!novelUrl) return; - - const novelName = loadedCheerio(el).find('h3.truyen-title > a').text(); - - let novelCover = loadedCheerio(el).find('img.cover').attr('src'); - if (novelCover && !isUrlAbsolute(novelCover)) { - novelCover = this.site + novelCover; - } - - const novel = { - path: novelUrl, - name: novelName, - cover: novelCover, - }; - novels.push(novel); - }); - return novels; - } - - async popularNovels( - pageNo: number, - { filters }: Plugin.PopularNovelsOptions, - ): Promise { - let link = this.site; - - if (filters.genres.value.length) link += filters.genres.value; - else link += filters.order.value; - - link += `?page=${pageNo}`; - - const result = await fetchApi(link); - const body = await result.text(); - - const loadedCheerio = parseHTML(body); - return this.parseNovels(loadedCheerio); - } - - async parseNovel(novelPath: string): Promise { - const url = this.site + novelPath; - const result = await fetchApi(url); - const body = await result.text(); - - let loadedCheerio = parseHTML(body); - - const novel: Plugin.SourceNovel = { - path: novelPath, - name: loadedCheerio('.book > img').attr('alt') || 'Untitled', - cover: this.site + loadedCheerio('.book > img').attr('src'), - summary: loadedCheerio('.desc-text').text().trim(), - chapters: [], - }; - - loadedCheerio('.info > div').each((i, el) => { - const detailName = loadedCheerio(el).find('h3').text(); - const detail = loadedCheerio(el) - .find('a') - .map((a, ex) => loadedCheerio(ex).text()) - .toArray() - .join(','); - - switch (detailName) { - case 'Author:': - novel.author = detail; - break; - case 'Status:': - novel.status = detail; - break; - case 'Genre:': - novel.genres = detail; - break; - } - }); - - const novelId = loadedCheerio('#rating').attr('data-novel-id'); - - const getChapters = async (id: string) => { - const chaptersUrl = this.site + '/ajax/chapter-option?novelId=' + id; - - const data = await fetchApi(chaptersUrl); - const chapters = await data.text(); - - loadedCheerio = parseHTML(chapters); - - const chapter: Plugin.ChapterItem[] = []; - - loadedCheerio('select > option').each((i, el) => { - const chapterName = loadedCheerio(el).text(); - const chapterUrl = loadedCheerio(el).attr('value'); - - if (!chapterUrl) return; - - chapter.push({ - name: chapterName, - path: chapterUrl, - }); - }); - - return chapter; - }; - - if (novelId) { - novel.chapters = await getChapters(novelId); - } - - return novel; - } - - async parseChapter(chapterPath: string): Promise { - const result = await fetchApi(this.site + chapterPath); - const body = await result.text(); - - const loadedCheerio = parseHTML(body); - - const chapterText = loadedCheerio('#chapter-content').html() || ''; - - return chapterText; - } - - async searchNovels( - searchTerm: string, - page: number, - ): Promise { - const url = `${this.site}/search?keyword=${encodeURIComponent(searchTerm)}&page=${page}`; - const result = await fetchApi(url); - const body = await result.text(); - - const loadedCheerio = parseHTML(body); - const lastPage = loadedCheerio('.pagination-container ul li a') - .last() - .attr('data-page'); - if (!lastPage) { - // no pagination - if (page > 1) return []; - } else { - if (parseInt(lastPage) && page > parseInt(lastPage)) return []; - } - return this.parseNovels(loadedCheerio); - } - - filters = { - order: { - value: '/most-popular', - label: 'Order by', - options: [ - { label: 'Latest Release', value: '/latest-release-novel' }, - { label: 'Hot Novel', value: '/hot-novel' }, - { label: 'Completed Novel', value: '/completed-novel' }, - { label: 'Most Popular', value: '/most-popular' }, - ], - type: FilterTypes.Picker, - }, - genres: { - value: '', - label: 'Genre', - options: [ - { label: 'None', value: '' }, - { label: 'Shounen', value: '/genre/Shounen' }, - { label: 'Harem', value: '/genre/Harem' }, - { label: 'Comedy', value: '/genre/Comedy' }, - { label: 'Martial Arts', value: '/genre/Martial+Arts' }, - { label: 'School Life', value: '/genre/School+Life' }, - { label: 'Mystery', value: '/genre/Mystery' }, - { label: 'Shoujo', value: '/genre/Shoujo' }, - { label: 'Romance', value: '/genre/Romance' }, - { label: 'Sci-fi', value: '/genre/Sci-fi' }, - { label: 'Gender Bender', value: '/genre/Gender+Bender' }, - { label: 'Mature', value: '/genre/Mature' }, - { label: 'Fantasy', value: '/genre/Fantasy' }, - { label: 'Horror', value: '/genre/Horror' }, - { label: 'Drama', value: '/genre/Drama' }, - { label: 'Tragedy', value: '/genre/Tragedy' }, - { label: 'Supernatural', value: '/genre/Supernatural' }, - { label: 'Ecchi', value: '/genre/Ecchi' }, - { label: 'Xuanhuan', value: '/genre/Xuanhuan' }, - { label: 'Adventure', value: '/genre/Adventure' }, - { label: 'Action', value: '/genre/Action' }, - { label: 'Psychological', value: '/genre/Psychological' }, - { label: 'Xianxia', value: '/genre/Xianxia' }, - { label: 'Wuxia', value: '/genre/Wuxia' }, - { label: 'Historical', value: '/genre/Historical' }, - { label: 'Slice of Life', value: '/genre/Slice+of+Life' }, - { label: 'Seinen', value: '/genre/Seinen' }, - { label: 'Lolicon', value: '/genre/Lolicon' }, - { label: 'Adult', value: '/genre/Adult' }, - { label: 'Josei', value: '/genre/Josei' }, - { label: 'Sports', value: '/genre/Sports' }, - { label: 'Smut', value: '/genre/Smut' }, - { label: 'Mecha', value: '/genre/Mecha' }, - { label: 'Yaoi', value: '/genre/Yaoi' }, - { label: 'Shounen Ai', value: '/genre/Shounen+Ai' }, - { label: 'Magical Realism', value: '/genre/Magical+Realism' }, - { label: 'Video Games', value: '/genre/Video+Games' }, - ], - type: FilterTypes.Picker, - }, - } satisfies Filters; -} - -export default new AllNovelFullPlugin(); diff --git a/src/plugins/english/freewebnovel.ts b/src/plugins/english/freewebnovel.ts deleted file mode 100644 index 3afd269..0000000 --- a/src/plugins/english/freewebnovel.ts +++ /dev/null @@ -1,422 +0,0 @@ -import { Plugin } from '@typings/plugin'; -import { fetchApi } from '@libs/fetch'; -import { Parser } from 'htmlparser2'; -import { Filters, FilterTypes } from '@libs/filterInputs'; - -class FreeWebNovel implements Plugin.PluginBase { - id = 'FWN.com'; - name = 'Free Web Novel'; - site = 'https://freewebnovel.com/'; - version = '2.0.2'; - icon = 'src/en/freewebnovel/icon.png'; - - lastSearch: number | null = null; - searchInterval = 3400; - - async sleep(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - async getHtml(url: string): Promise { - const r = await fetchApi(url); - if (!r.ok) - throw new Error( - `Could not reach site (${r.status}: ${r.statusText}) try to open in webview.`, - ); - return await r.text(); - } - - parseNovels(html: string) { - const baseUrl = this.site; - const novels: Plugin.NovelItem[] = []; - let tempNovel: Partial = {}; - let state: ParsingState = ParsingState.Idle; - const parser = new Parser({ - onopentag(name, attribs) { - if (attribs.class === 'li-row') { - state = ParsingState.Novel; - } - if (state !== ParsingState.Novel) return; - - switch (name) { - case 'a': - tempNovel.path = attribs.href?.split('/').slice(1, 3).join('/'); - break; - case 'img': - const coverSrc = attribs.src || ''; - tempNovel.name = attribs.title; - tempNovel.cover = coverSrc.startsWith('http') - ? coverSrc - : baseUrl + coverSrc; - break; - } - }, - - onclosetag(name) { - if (name === 'div') { - if (tempNovel.name && tempNovel.path) { - novels.push({ ...tempNovel } as Plugin.NovelItem); - } - tempNovel = {}; - state = ParsingState.Idle; - } - }, - }); - - parser.write(html); - parser.end(); - - return novels; - } - - async popularNovels( - page: number, - { - showLatestNovels, - filters, - }: Plugin.PopularNovelsOptions, - ): Promise { - let path: string; - - if (showLatestNovels) { - path = 'sort/latest-novels/'; - } else if (filters?.genres?.value) { - path = filters.genres.value; - } else { - if (page !== 1) return []; - path = 'most-popular/'; - page = 0; - } - - const url = `${this.site}${path}${page}`; - const body = await this.getHtml(url); - return this.parseNovels(body); - } - - async parseNovel(novelPath: string): Promise { - const baseUrl = this.site; - const body = await this.getHtml(this.site + novelPath); - const novel: Partial = { - path: novelPath, - }; - - let i = 0; - let state: ParsingState = ParsingState.Idle; - let tempChapter: Partial = {}; - const chapter: Plugin.ChapterItem[] = []; - const summaryParts: string[] = []; - const statusParts: string[] = []; - const authorParts: string[] = []; - const genreArray: string[] = []; - - const parser = new Parser({ - onopentag(name, attribs) { - switch (name) { - case 'div': - switch (attribs.class) { - case 'm-imgtxt': - state = ParsingState.NovelInfo; - break; - case 'inner': - state = ParsingState.Summary; - break; - case 'm-desc': - state = ParsingState.Idle; - break; - } - break; - case 'img': - if (state === ParsingState.NovelInfo) { - novel.name = attribs.title; - novel.cover = baseUrl + attribs.src; - } - break; - case 'span': - if (state === ParsingState.NovelInfo) { - if (attribs.title) { - const map: Record = { - 'Genre': ParsingState.Genre, - 'Author': ParsingState.Author, - 'Status': ParsingState.Status, - }; - state = map[attribs.title] ?? state; - } - } - break; - case 'ul': - if (attribs.id === 'idData') { - state = ParsingState.ChapterList; - } - break; - case 'a': - if (state === ParsingState.ChapterList) { - i++; - const href = attribs.href; - state = ParsingState.Chapter; - - tempChapter.name = attribs.title || `Chapter ${i}`; - tempChapter.releaseTime = null; - tempChapter.chapterNumber = i; - tempChapter.path = - href?.slice(1) || - novelPath.replace('.html', `/chapter-${i}.html`); - } - break; - default: - return; - } - }, - - ontext(text) { - switch (state) { - case ParsingState.Genre: - genreArray.push(text); - break; - case ParsingState.Author: - authorParts.push(text); - break; - case ParsingState.Status: - statusParts.push(text.trim()); - break; - case ParsingState.Summary: - summaryParts.push(text.trim()); - break; - default: - return; - } - }, - - onclosetag(name) { - switch (name) { - case 'div': - switch (state) { - case ParsingState.Genre: - case ParsingState.Author: - case ParsingState.Status: - state = ParsingState.NovelInfo; - break; - case ParsingState.Summary: - state = ParsingState.Idle; - } - case 'a': - if (state === ParsingState.Chapter) { - if (tempChapter.name && tempChapter.path) { - chapter.push({ ...tempChapter } as Plugin.ChapterItem); - } - tempChapter = {}; - state = ParsingState.ChapterList; - } - break; - case 'ul': - if (state === ParsingState.ChapterList) { - state = ParsingState.Idle; - } - break; - default: - return; - } - }, - - onend() { - novel.chapters = chapter; - novel.genres = genreArray.join('').trim(); - novel.author = authorParts.join('').trim(); - novel.summary = summaryParts.join('\n\n'); - novel.status = statusParts - .join('') - .toLowerCase() - .replace(/\b\w/g, char => char.toUpperCase()); - }, - }); - - parser.write(body); - parser.end(); - - return novel as Plugin.SourceNovel; - } - - async parseChapter(chapterPath: string): Promise { - const html = await this.getHtml(this.site + chapterPath); - const match = html.match(/e\("([^]+?)"/); - const check = match ? match[1] : ''; - const body = check ? html.replace(check, '') : html; - - let depth = 0; - let state: ParsingState = ParsingState.Idle; - const chapterHtml: string[] = []; - let skipClosingTag = false; - let currentTagToSkip = ''; - - type EscapeChar = '&' | '<' | '>' | '"' | "'" | ' '; - const escapeRegex = /[&<>"' ]/g; - const escapeMap: Record = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - ' ': ' ', - }; - const escapeHtml = (text: string): string => - text.replace(escapeRegex, char => escapeMap[char as EscapeChar]); - - const parser = new Parser({ - onopentag(name, attribs) { - const attrib = attribs.class?.trim(); - switch (state) { - case ParsingState.Idle: - if (attrib === 'txt') { - state = ParsingState.Chapter; - depth++; - } - break; - case ParsingState.Chapter: - if (name === 'div') depth++; - break; - default: - return; - } - - if (state === ParsingState.Chapter) { - const attrKeys = Object.keys(attribs); - - if (attrKeys.length === 0) { - chapterHtml.push(`<${name}>`); - } else if (attrKeys.every(key => attribs[key].trim() === '')) { - // Handle tags with empty attributes as text content - // eg: novel/rising-up-from-a-nobleman-to-intergalactic-warlord/chapter-184 - skipClosingTag = true; - currentTagToSkip = name; - const uppercaseName = name.replace(/\b\w/g, char => - char.toUpperCase(), - ); - chapterHtml.push( - escapeHtml(`<${uppercaseName} ${attrKeys.join(' ')}>`), - ); - } else { - // Normal tag with attributes - const attrString = attrKeys - .map(key => ` ${key}="${attribs[key].replace(/"/g, '"')}"`) - .join(''); - chapterHtml.push(`<${name}${attrString}>`); - } - } - }, - - ontext(text) { - if (state === ParsingState.Chapter) { - chapterHtml.push(escapeHtml(text)); - } - }, - - onclosetag(name) { - if (state !== ParsingState.Chapter) return; - - if (!parser['isVoidElement'](name)) { - if (skipClosingTag && name === currentTagToSkip) { - skipClosingTag = false; - currentTagToSkip = ''; - } else { - chapterHtml.push(``); - } - } - - if (name === 'div') depth--; - if (depth === 0) state = ParsingState.Stopped; - }, - }); - - parser.write(body); - parser.end(); - - return chapterHtml.join(''); - } - - async searchNovels(searchTerm: string): Promise { - const now = Date.now(); - if (this.lastSearch && now - this.lastSearch <= this.searchInterval) { - await this.sleep(this.searchInterval); - } - - const r = await fetchApi(this.site + 'search', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ searchkey: searchTerm }).toString(), - }); - - this.lastSearch = Date.now(); - - if (!r.ok) { - throw new Error( - `Could not reach site ('${r.status}') try to open in webview.`, - ); - } - - const body = await r.text(); - const alertText = body.match(/alert\((.*?)\)/)?.[1] || ''; - if (alertText) throw new Error(alertText); - - return this.parseNovels(body); - } - - filters = { - genres: { - type: FilterTypes.Picker, - label: 'Genre', - value: '', - options: [ - { label: 'Action', value: 'genre/Action/' }, - { label: 'Adult', value: 'genre/Adult/' }, - { label: 'Adventure', value: 'genre/Adventure/' }, - { label: 'Comedy', value: 'genre/Comedy/' }, - { label: 'Drama', value: 'genre/Drama/' }, - { label: 'Eastern', value: 'genre/Eastern' }, - { label: 'Ecchi', value: 'genre/Ecchi/' }, - { label: 'Fantasy', value: 'genre/Fantasy/' }, - { label: 'Gender Bender', value: 'genre/Gender+Bender/' }, - { label: 'Harem', value: 'genre/Harem/' }, - { label: 'Historical', value: 'genre/Historical/' }, - { label: 'Horror', value: 'genre/Horror/' }, - { label: 'Josei', value: 'genre/Josei/' }, - { label: 'Game', value: 'genre/Game/' }, - { label: 'Martial Arts', value: 'genre/Martial+Arts/' }, - { label: 'Mature', value: 'genre/Mature/' }, - { label: 'Mecha', value: 'genre/Mecha/' }, - { label: 'Mystery', value: 'genre/Mystery/' }, - { label: 'Psychological', value: 'genre/Psychological/' }, - { label: 'Reincarnation', value: 'genre/Reincarnation' }, - { label: 'Romance', value: 'genre/Romance/' }, - { label: 'School Life', value: 'genre/School+Life/' }, - { label: 'Sci-fi', value: 'genre/Sci-fi/' }, - { label: 'Seinen', value: 'genre/Seinen/' }, - { label: 'Shoujo', value: 'genre/Shoujo/' }, - { label: 'Shounen Ai', value: 'genre/Shounen+Ai/' }, - { label: 'Shounen', value: 'genre/Shounen/' }, - { label: 'Slice of Life', value: 'genre/Slice+of+Life/' }, - { label: 'Smut', value: 'genre/Smut/' }, - { label: 'Sports', value: 'genre/Sports/' }, - { label: 'Supernatural', value: 'genre/Supernatural/' }, - { label: 'Tragedy', value: 'genre/Tragedy/' }, - { label: 'Wuxia', value: 'genre/Wuxia/' }, - { label: 'Xianxia', value: 'genre/Xianxia/' }, - { label: 'Xuanhuan', value: 'genre/Xuanhuan/' }, - { label: 'Yaoi', value: 'genre/Yaoi/' }, - ], - }, - } satisfies Filters; -} - -export default new FreeWebNovel(); - -enum ParsingState { - Idle, - Novel, - Genre, - Author, - Status, - Summary, - NovelInfo, - ChapterList, - Chapter, - Stopped, -}