diff --git a/plugins/english/mznovels.ts b/plugins/english/mznovels.ts new file mode 100644 index 000000000..3fd928dba --- /dev/null +++ b/plugins/english/mznovels.ts @@ -0,0 +1,361 @@ +import { fetchApi, fetchProto, fetchText } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { Filters, FilterToValues, FilterTypes } from '@libs/filterInputs'; +import { CheerioAPI, load as loadCheerio } from 'cheerio'; +import { defaultCover } from '@libs/defaultCover'; +import { NovelStatus } from '@libs/novelStatus'; +// import { isUrlAbsolute } from '@libs/isAbsoluteUrl'; +import { storage /*localStorage, sessionStorage*/ } from '@libs/storage'; +// import { encode, decode } from 'urlencode'; +// import dayjs from 'dayjs'; +// import { Parser } from 'htmlparser2'; + +class MzNovelsPlugin implements Plugin.PluginBase { + id = 'mznovels'; + name = 'MZ Novels'; + icon = 'src/en/mznovels/icon.png'; + customCSS = 'src/en/mznovels/customCss.css'; + customJS = 'src/en/mznovels/customJs.js'; + site = 'https://mznovels.com'; + version = '1.0.1'; + filters = { + rank_type: { + label: 'Ranking Type', + options: [ + { label: 'Original', value: 'original' }, + { label: 'Translated', value: 'translated' }, + { label: 'Fanfiction', value: 'fanfiction' }, + ], + type: FilterTypes.Picker, + value: 'original', + }, + rank_period: { + label: 'Ranking Period', + options: [ + { label: 'Daily', value: 'daily' }, + { label: 'Weekly', value: 'weekly' }, + { label: 'Monthly', value: 'monthly' }, + ], + type: FilterTypes.Picker, + value: 'daily', + }, + } satisfies Filters; + imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined; + + pluginSettings = { + authorNotes: { + label: 'Author Notes', + type: FilterTypes.Picker, + value: 'footnotes', + options: [ + { label: 'Inline', value: 'inline' }, + { label: 'Footnotes', value: 'footnotes' }, + { label: 'None', value: 'none' }, + ], + }, + } satisfies Plugin.PluginSettings; + + //flag indicates whether access to LocalStorage, SesesionStorage is required. + webStorageUtilized?: boolean = false; + + normalizePath(path: string, withDomain?: boolean): string; + normalizePath( + path: string | undefined, + withDomain?: boolean, + ): string | undefined; + normalizePath(path: string | undefined, withDomain: boolean = true) { + if (!path) { + return path; + } + if (path.startsWith('/')) { + if (withDomain) { + return this.site + path; + } + return path; + } else { + if (!path.startsWith(this.site)) { + console.warn("path doesn't seem to belong to this site"); + } + if (!withDomain) { + return path.slice(this.site.length); + } + return path; + } + } + + normalizeAvatar(path: string) { + path = this.normalizePath(path, true); + if (path === 'https://mznovels.com/media/avatars/default.png') { + return defaultCover; + } + return path; + } + + parseSearchResults($: CheerioAPI, pageNo: number): Plugin.NovelItem[] { + // When a page number larger than the max is used, mznovels simply repeats the final page. + // Here we detect this and return an empty page instead. + const curPage = $('div.pagination > span.active').text(); + if (curPage !== pageNo.toString()) { + // throw new Error(`Incorrect page ${curPage} when searching for page ${pageNo}`); + return []; + } + + const novels: Plugin.NovelItem[] = []; + $( + 'ul.search-results-list > li.search-result-item:not(.ad-result-item)', + ).each((idx, ele) => { + const $ele = $(ele); + const name = $ele.find('h2.search-result-title').first().text(); + const path = this.normalizePath( + $ele.find('a.search-result-title-link').first().attr('href'), + ); + const cover = + this.normalizePath($ele.find('img.search-result-image').attr('src')) ?? + defaultCover; + if (path) { + novels.push({ name, path, cover }); + } + }); + return novels; + } + + applyLocalFilters( + novels: Plugin.NovelItem[], + filters: FilterToValues, + ): Plugin.NovelItem[] { + // TODO + return novels; + } + + async popularNovels( + pageNo: number, + { + showLatestNovels, + filters, + }: Plugin.PopularNovelsOptions, + ): Promise { + let url: string; + if (showLatestNovels) { + url = this.normalizePath(`/latest-updates/?page=${pageNo}`); + } else { + url = this.normalizePath( + `/rankings/${filters?.rank_type?.value ?? 'original'}?period=${filters?.rank_period?.value ?? 'daily'}&page=${pageNo}`, + ); + } + const result = await fetchApi(url); + if (!result.ok) { + throw new Error('Captcha error, please open in webview'); + } + const body = await result.text(); + const loadedCheerio = loadCheerio(body); + + // TODO: if I apply the filters here, but one search page doesn't happen to contain any matches, LNReader will believe there are no more pages! + // This means that the page numbers for this function and the website need to be diverged in a consistent way. How? + return this.parseSearchResults(loadedCheerio, pageNo); + } + + async parseNovel(novelPath: string): Promise { + const novel: Plugin.SourceNovel = { + path: novelPath, + name: 'Untitled', + }; + + const url = this.normalizePath(novelPath); + const result = await fetchApi(url); + if (!result.ok) { + throw new Error('Captcha error, please open in webview'); + } + const body = await result.text(); + const $ = loadCheerio(body); + + // TODO: get here data from the site and + // un-comment and fill-in the relevant fields + + novel.name = $('h1.novel-title').first().text(); + novel.cover = + this.normalizePath($('img#novel-cover-image').attr('src')) ?? + defaultCover; + + // Original, Translated, Fanfiction + const categoryStr = $('span.category-value').text(); + let category; + switch (categoryStr) { + case 'Original': + category = 'original'; + break; + case 'Translated': + category = 'translated'; + break; + case 'Fanfiction': + category = 'fanfiction'; + break; + default: + category = null; + break; + } + + let author = $('p.novel-author > a').text(); + if (category === 'translated') { + let origAuthor = 'Unknown'; + $('div.translation-info-item').each((idx, ele) => { + const $ele = $(ele); + if ( + $ele.find('span.translation-label').text() === 'Original Author:' && + !$ele.find('span.translation-value').hasClass('not-provided') + ) { + origAuthor = $ele.find('span.translation-value').text(); + } + }); + + author = `${origAuthor} (translated by: ${author})`; + } + + // novel.artist = ''; + novel.author = author; + + const tags = []; + if (category) { + tags.push(`Category: ${category}`); + } + + $('div.genres-container > a.genre').each((idx, ele) => { + tags.push($(ele).text().replace(',', '_')); + }); + + $('div.tags-container > a.tag').each((idx, ele) => { + tags.push($(ele).text().replace(',', '_')); + }); + + novel.genres = tags.join(', '); + + const statusIndicator = $('span.status-indicator'); + novel.status = statusIndicator.hasClass('completed') + ? NovelStatus.Completed + : NovelStatus.Ongoing; + + novel.summary = ( + $('p.summary-text').prop('innerHTML') ?? '' + ).trim(); + const ratingStr = $('span.rating-score').text(); + if (ratingStr) { + const ratingNum = ratingStr.match(/^\((\d+\.\d+)\)$/)?.groups?.[1]; + if (ratingNum) { + novel.rating = parseFloat(ratingNum); + } + } + + let pageNo = 1; + let $page = $; + const lastPageLink = $('div#chapters .pagination') + .children() + .last() + .filter((i, el) => el.tagName === 'a') + .attr('href'); + const maxPage = lastPageLink ? parseInt(lastPageLink.split('=')[1]) : 1; + const chaptersBackwards: Plugin.ChapterItem[] = []; + + while (pageNo <= maxPage) { + if (pageNo > 1) { + const pageUrl = url + `?page=${pageNo}`; + const res = await fetchApi(pageUrl); + if (!res.ok) { + throw new Error('Captcha error, please open in webview'); + } + $page = loadCheerio(await res.text()); + } + $page('ul.chapter-list > li.chapter-item').each((idx, el) => { + const $el = $page(el); + chaptersBackwards.push({ + name: $el.find('span.chapter-title-text').text(), + path: this.normalizePath($el.find('a.chapter-link').attr('href'))!!, + // releaseTime: $el.find('span.chapter-date').text(), + }); + }); + pageNo++; + } + + const chapters = chaptersBackwards.reverse().map((v, i) => { + v.chapterNumber = i + 1; + return v; + }); + + novel.chapters = chapters; + return novel; + } + async parseChapter(chapterPath: string): Promise { + const url = this.normalizePath(chapterPath); + const result = await fetchApi(url); + if (!result.ok) { + throw new Error('Captcha error, please open in webview'); + } + const body = await result.text(); + const $ = loadCheerio(body); + + const content = $('div.formatted-content'); + content.remove('div.chapter-ad-banner'); + + const authorNotes = $('.author-feedback'); + const authorNotesMode = + storage.get('authorNotes') ?? this.pluginSettings.authorNotes.value; // TODO: needs to be done properly in LNReader + console.log(storage.getAllKeys().map(k => [k, storage.get(k)])); + if (authorNotesMode !== 'inline') { + console.log(authorNotes); + if (authorNotesMode === 'footnotes') { + const footnotes: string[] = []; + authorNotes.each((i, el) => { + const $el = $(el); + const content = $el.attr('data-note'); + if (!content) return; + + footnotes.push(content); + $el.append( + `${i + 1}`, + ); + }); + console.log(footnotes); + content.append(` +
+ ${footnotes + .map( + (v, i) => ` + ${i + 1} + ${v} + `, + ) + .join('')} +
+ `); + } + authorNotes.children().unwrap(); + } + + $('.author_note > .note_content').each((i, el) => { + content.append(` +
+

Author's Note

+

${$(el).text()}

+
+ `); + }); + + return content.html()!!; + } + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + let url = this.normalizePath(`/search/?q=${searchTerm}`); + const result = await fetchApi(url); + if (!result.ok) { + throw new Error('Captcha error, please open in webview'); + } + const body = await result.text(); + const loadedCheerio = loadCheerio(body); + + return this.parseSearchResults(loadedCheerio, pageNo); + } + + resolveUrl = (path: string, isNovel?: boolean) => this.normalizePath(path); +} + +export default new MzNovelsPlugin(); \ No newline at end of file diff --git a/plugins/english/relibrary.ts b/plugins/english/relibrary.ts index 0f0685cc1..fb12f36fb 100644 --- a/plugins/english/relibrary.ts +++ b/plugins/english/relibrary.ts @@ -3,7 +3,6 @@ import { Plugin } from '@/types/plugin'; import { Filters } from '@libs/filterInputs'; import { load as loadCheerio } from 'cheerio'; import { defaultCover } from '@libs/defaultCover'; -import { NovelItem } from '../../test_web/static/js'; import { NovelStatus } from '@libs/novelStatus'; type FuzzySearchOptions = { @@ -163,8 +162,9 @@ class ReLibraryPlugin implements Plugin.PluginBase { id = 'ReLib'; name = 'Re:Library'; icon = 'src/en/relibrary/icon.png'; + customCSS = 'src/en/relibrary/customCss.css'; site = 'https://re-library.com'; - version = '1.0.2'; + version = '1.1.0'; imageRequestInit: Plugin.ImageRequestInit = { headers: { Referer: 'https://re-library.com/', @@ -176,27 +176,48 @@ class ReLibraryPlugin implements Plugin.PluginBase { caseSensitive: false, }); + private ensurePageOk(response: Response, body: string) { + if (!response.ok) { + throw new Error(`HTML Error ${response.status}: ${response.statusText}`); + } + const title = body.match(/(.*?)<\/title>/)?.[1]?.trim(); + + if ( + title && + (title == 'Bot Verification' || + title == 'You are being redirected...' || + title == 'Un instant...' || + title == 'Just a moment...' || + title == 'Redirecting...') + ) { + throw new Error('Captcha error, please open in webview'); + } + } + + private ensureCover(coverUrl: string | undefined) { + if (!coverUrl) return defaultCover; + + if (coverUrl.endsWith('no-image.png')) return defaultCover; + + return coverUrl; + } + private async popularNovelsInner(url: string): Promise<Plugin.NovelItem[]> { const novels: Plugin.NovelItem[] = []; const result = await fetchApi(url); const body = await result.text(); + await this.ensurePageOk(result, body); - const loadedCheerio = loadCheerio(body); - loadedCheerio('.entry-content > ol > li').each((_i, el) => { - const novel: Partial<NovelItem> = {}; - novel.name = loadedCheerio(el).find('h3 > a').text(); - novel.path = loadedCheerio(el) - .find('table > tbody > tr > td > a') - .attr('href'); + const $ = loadCheerio(body); + $('.entry-content > ol > li').each((_i, el) => { + const novel: Partial<Plugin.NovelItem> = {}; + novel.name = $(el).find('h3 > a').text(); + novel.path = $(el).find('table > tbody > tr > td > a').attr('href'); if (novel.name === undefined || novel.path === undefined) return; - novel.cover = - loadedCheerio(el) - .find('table > tbody > tr > td > a > img') - .attr('data-cfsrc') || - loadedCheerio(el) - .find('table > tbody > tr > td > a > img') - .attr('src') || - defaultCover; + novel.cover = this.ensureCover( + $(el).find('table > tbody > tr > td > a > img').attr('data-cfsrc') || + $(el).find('table > tbody > tr > td > a > img').attr('src'), + ); if (novel.path.startsWith(this.site)) { novel.path = novel.path.slice(this.site.length); } @@ -209,21 +230,22 @@ class ReLibraryPlugin implements Plugin.PluginBase { const novels: Plugin.NovelItem[] = []; const result = await fetchApi(url); const body = await result.text(); + await this.ensurePageOk(result, body); - const loadedCheerio = loadCheerio(body); - loadedCheerio('article.type-page.page').each((_i, el) => { + const $ = loadCheerio(body); + $('article.type-page.page').each((_i, el) => { const novel: Partial<Plugin.NovelItem> = {}; - novel.name = loadedCheerio(el).find('.entry-title').text(); - novel.path = loadedCheerio(el).find('.entry-title a').attr('href'); + novel.name = $(el).find('.entry-title').text(); + novel.path = $(el).find('.entry-title a').attr('href'); if (novel.path === undefined || novel.name === undefined) return; - novel.cover = - loadedCheerio(el) + novel.cover = this.ensureCover( + $(el) .find('.entry-content > table > tbody > tr > td > a >img') .attr('data-cfsrc') || - loadedCheerio(el) - .find('.entry-content > table > tbody > tr > td > a >img') - .attr('src') || - defaultCover; + $(el) + .find('.entry-content > table > tbody > tr > td > a >img') + .attr('src'), + ); if (novel.path.startsWith(this.site)) { novel.path = novel.path.slice(this.site.length); } @@ -259,95 +281,112 @@ class ReLibraryPlugin implements Plugin.PluginBase { const result = await fetchApi(`${this.site}/${novelPath}`); const body = await result.text(); + await this.ensurePageOk(result, body); - const loadedCheerio = loadCheerio(body); + const $ = loadCheerio(body); // If it doesn't find the name I should just throw an error (or early return) since the scraping is broken - novel.name = loadedCheerio('header.entry-header > .entry-title') - .text() - .trim(); + novel.name = $('header.entry-header > .entry-title').text().trim(); if (novel.name === undefined || novel.name === '404 – Page not found') throw new Error(`Invalid novel for url ${novelPath}`); // Find the cover - novel.cover = - loadedCheerio('.entry-content > table img').attr('data-cfsrc') || - loadedCheerio('.entry-content > table img').attr('src') || - defaultCover; + novel.cover = this.ensureCover( + // $('.entry-content > table > tbody > tr > td > a > img') + $('.entry-content > table img').attr('data-cfsrc') || + $('.entry-content > table img').attr('src'), + ); novel.status = NovelStatus.Unknown; - loadedCheerio('.entry-content > table > tbody > tr > td > p').each( - function (_i, el) { - // Handle the novel status - // Sadly some novels just state the status inside the summary... - if ( - loadedCheerio(el) - .find('strong') - .text() - .toLowerCase() - .trim() - .startsWith('status') - ) { - loadedCheerio(el).find('strong').remove(); - const status = loadedCheerio(el).text().toLowerCase().trim(); - if (status.includes('on-going')) { - novel.status = NovelStatus.Ongoing; - } else if (status.includes('completed')) { - novel.status = NovelStatus.Completed; - } else if (status.includes('hiatus')) { - novel.status = NovelStatus.OnHiatus; - } else if (status.includes('cancelled')) { - novel.status = NovelStatus.Cancelled; - } else { - novel.status = loadCheerio(el).text(); - } + $('.entry-content > table > tbody > tr > td > p').each(function (_i, el) { + // Handle the novel status + // Sadly some novels just state the status inside the summary... + if ( + $(el).find('strong').text().toLowerCase().trim().startsWith('status') + ) { + $(el).find('strong').remove(); + const status = $(el).text().toLowerCase().trim(); + if (status.includes('on-going')) { + novel.status = NovelStatus.Ongoing; + } else if (status.includes('completed')) { + novel.status = NovelStatus.Completed; + } else if (status.includes('hiatus')) { + novel.status = NovelStatus.OnHiatus; + } else if (status.includes('cancelled')) { + novel.status = NovelStatus.Cancelled; + } else { + novel.status = loadCheerio(el).text(); } - // Handle the genres - else if ( - loadedCheerio(el) - .find('strong') - .text() - .toLowerCase() - .trim() - .startsWith('Category') - ) { - loadedCheerio(el).find('strong').remove(); - novel.genres = loadedCheerio(el).text(); + } + // Handle the genres + else if ( + $(el).find('strong').text().toLowerCase().trim().startsWith('Category') + ) { + $(el).find('strong').remove(); + // previously, list of '> span > a' + novel.genres = $(el).text(); + } + }); + + // Handle the author names + // Both the author and the translator (if present) seem to be written out as links, + // and the paragraph should contain at most two of them (they SHOULD always be first). + $('.entry-content > table > tbody > tr > td > span:has(> a)').each( + (_i, el) => { + const $el = $(el); + novel.author = $el.find('a:nth-child(1)').text(); + const translator = $el.find('a:nth-child(2)').first().text(); + if (!!translator) { + novel.author += ` (translated by: ${translator})`; } }, ); - novel.summary = loadedCheerio( + novel.summary = $( '.entry-content > div.su-box > div.su-box-content', ).text(); - const chapters: Plugin.ChapterItem[] = []; + const chapters: (Plugin.ChapterItem & { epoch: number | null })[] = []; let chapter_idx = 0; - loadedCheerio('.entry-content > div.su-accordion').each((_i1, el) => { - loadedCheerio(el) - .find('li > a') + $('.entry-content > div.su-accordion').each((_i1, el) => { + $(el) + .find('li:has(> a)') .each((_i2, chap_el) => { - chapter_idx += 1; - let chap_path = loadedCheerio(chap_el).attr('href')?.trim(); - if ( - loadedCheerio(chap_el).text() === undefined || - chap_path === undefined - ) - return; + const $a = $(chap_el).find('a'); + + let chap_path = $a.attr('href')?.trim(); + if ($a.text() === undefined || chap_path === undefined) return; if (chap_path.startsWith(this.site)) { chap_path = chap_path.slice(this.site.length); } + + const epochStr = $(chap_el).attr('data-date'); + // if we can't get the released time (at least without any additional fetches), set it to null purposfully + let epoch = null; + let releaseTime = null; + if (!!epochStr) { + epoch = parseInt(epochStr); + if (!isNaN(epoch)) { + releaseTime = new Date(epoch * 1000).toISOString(); + } + } + chapters.push({ - name: loadedCheerio(chap_el).text(), + name: $a.text(), path: chap_path, - chapterNumber: chapter_idx, + chapterNumber: 0, // we KNOW that we can't get the released time (at least without any additional fetches), so set it to null purposfully - releaseTime: null, + releaseTime, + epoch, }); }); }); + chapters.map((c, i) => { + c.chapterNumber = i + 1; + return c; + }); novel.chapters = chapters; return novel as Plugin.NovelItem; @@ -357,40 +396,65 @@ class ReLibraryPlugin implements Plugin.PluginBase { // parse chapter text here const result = await fetchApi(`${this.site}/${chapterPath}`); const body = await result.text(); + await this.ensurePageOk(result, body); - const loadedCheerio = loadCheerio(body); + const $ = loadCheerio(body); + const postId = $('article').attr('id')?.split('-')?.[1] ?? null; - const entryContent = loadedCheerio('.entry-content'); - const pageLinkHr = entryContent.find('.PageLink + hr').first(); - if (pageLinkHr.length) { - // Remove all previous siblings before the .PageLink + hr - let prev = pageLinkHr.prev(); - while (prev.length) { - prev.remove(); - prev = pageLinkHr.prev(); - } - const pageLink = pageLinkHr.prev('.PageLink'); - if (pageLink.length) { - pageLink.remove(); - } - pageLinkHr.next().remove(); - pageLinkHr.remove(); + const content = $('article > div.entry-content'); + let topDelimiter = postId + ? content.find('> p:has(> span#more-' + postId + ')') + : undefined; + if (topDelimiter == null) { + topDelimiter = content.find('> p:nth-child(1)'); + } + + if (topDelimiter != null) { + topDelimiter.prevAll().remove(); + topDelimiter.remove(); } - // Find the first <hr> followed by a .PageLink and remove everything after - const hrAfterPageLink = entryContent.find('hr + .PageLink').first(); - if (hrAfterPageLink.length) { - let next = hrAfterPageLink.next(); - while (next.length) { - const temp = next.next(); - next.remove(); - next = temp; + content.find('div:has(>div.ad-slot)').remove(); + + const footnotes = content.find('ol.easy-footnotes-wrapper'); + + // TODO: use the buttons instead? + const btmDelimiter = content.find('> hr:has(~ hr#ref)').last(); + let authorNote = null; + if (btmDelimiter != null) { + authorNote = btmDelimiter.next('p'); + + btmDelimiter.nextAll().remove(); + btmDelimiter.remove(); + } + + // this is a really weird hack that some chapters of one specific novel do. + // I need to check if any others have a problem like this. + // Generally, we need to set up some kind of large-scale testing for + // edge cases like this. + content.find('p:has(>code)+code:has(+p:has(>code))').each((_i, el) => { + if ( + el.attribs.style.match(/(.*?;)?font-family: 'Merriweather', serif.*/) + ) { + $(el.prev!!).remove(); + $(el.next!!).remove(); + $(el).children().unwrap(); } - hrAfterPageLink.prev().remove(); - hrAfterPageLink.remove(); + }); + + content.append(footnotes); + if (authorNote != null) { + content.append(` + <blockquote class="author_note"> + <p>${authorNote.text()}</p> + </blockquote> + `); } - return entryContent.html() || ''; + content.find('div.PageLink').remove(); + content.find('table#fixed').remove(); + + return content.html() ?? ''; } async searchNovels( @@ -401,13 +465,14 @@ class ReLibraryPlugin implements Plugin.PluginBase { if (pageNo !== 1) return []; const novels: Plugin.NovelItem[] = []; - const req = await fetchApi(`${this.site}/translations/`); - const body = await req.text(); + const result = await fetchApi(`${this.site}/translations/`); + const body = await result.text(); + await this.ensurePageOk(result, body); - const loadedCheerio = loadCheerio(body); + const $ = loadCheerio(body); - loadedCheerio('article article').each((_i, el) => { - const e = loadedCheerio(el); + $('article article').each((_i, el) => { + const e = $(el); if (e.find('a').attr('href') && e.find('a').text()) { novels.push({ name: e.find('h4').text(), diff --git a/plugins/english/shanghaifantasy.ts b/plugins/english/shanghaifantasy.ts new file mode 100644 index 000000000..1f53511d1 --- /dev/null +++ b/plugins/english/shanghaifantasy.ts @@ -0,0 +1,762 @@ +import { fetchApi, fetchProto, fetchText } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { Filters, FilterTypes } from '@libs/filterInputs'; +import { load as loadCheerio } from 'cheerio'; +import { defaultCover } from '@libs/defaultCover'; +import { NovelStatus } from '@libs/novelStatus'; +// import { isUrlAbsolute } from '@libs/isAbsoluteUrl'; +// import { storage, localStorage, sessionStorage } from '@libs/storage'; +// import { encode, decode } from 'urlencode'; +// import dayjs from 'dayjs'; +// import { Parser } from 'htmlparser2'; + +const TAG_LIST: string[] = [ + "'70s", + '1960s', + '1970s', + '1970s Setting', + '1980', + '1980s', + '1990s', + '1V1', + "70's Setting", + '80s Setting', + 'Abandoned', + 'Abandoned Child', + 'ABO', + 'Abuse', + 'abuse scum', + 'Abuse Scumbag', + 'Abusive Characters', + 'Action', + 'Adopted Children', + 'Adorable Baby', + 'Adult', + 'Adventure', + 'Age Gap', + 'All Chapters Unlocked', + 'All Walks of Life', + 'Alpha Male', + 'Alpha/Beta/Omega/', + 'Alternate History', + 'Alternate World', + 'Amnesia', + 'Ancient', + 'Ancient and Modern Times', + 'Ancient China', + 'Ancient Farming', + 'Ancient Romance', + 'ancient style', + 'Ancient Times', + 'Angst', + 'Antagonist', + 'Apocalypse', + 'Arranged Marriage', + 'Artist (Painter)', + 'Awaken', + 'beast world', + 'Beastmen', + 'Beautiful Female Lead', + 'Beautiful protagonist', + 'BG', + 'Bickering Couple', + 'big brother', + 'BL', + 'Book Transmigration', + 'Both Pure', + 'Broken Mirror Reunion', + 'Building a Fortune', + 'Building Fortune', + 'Business', + 'Business management', + 'Bussiness', + 'Campus', + 'Campus Romance', + 'Cannon Fodder', + 'Capitalist Heiress', + 'Career Development', + 'Career Woman', + 'celebrity', + 'CEO', + 'Character Growth', + 'Charming Protagonist', + 'Chasing wife', + 'Child', + 'Childcare', + 'Childhood Love', + 'Childhood Sweethearts', + 'Chubby MC', + 'Cold-Hearted Prince', + 'College Life', + 'Comedy', + 'coming-of-age', + 'Competition', + 'contemporary', + 'Contract Marriage', + 'Cooking', + 'Countryside', + 'Court Marquis', + 'Court Nobility', + 'Crematorium Arc', + 'Crime', + 'Crime Fiction', + 'Crime Investigation', + 'Criminal', + 'Criminal Investigation', + 'cross-class encounters', + 'Crossing', + 'Cultivation', + 'Cunning Beauty', + 'Cute Babies', + 'cute baby', + 'Cute Child', + 'Cute Children', + 'Cute Protagonist', + 'Daily life', + 'Daily life with the Army', + 'Dark Villain', + 'Defying Fate', + 'Delicate Beauty', + 'Demons', + 'Depression', + 'Devoted Love', + 'Dimensional Space', + 'Disguise', + 'Divorce', + 'Divorced', + 'domi', + 'Doting Brother', + 'Doting Husband', + 'Doting Love Interest', + 'doting wife', + 'Double Purity', + 'Drama', + 'Dual Male Leads', + 'Easy', + 'Ecchi', + 'Elite', + 'Emperor', + 'Emptying Supplies', + 'Enemies to Lovers', + 'Ensemble Cast', + 'Ensemble Cast of Cannon Fodders', + 'Entertainment', + 'Entertainment Industry', + 'Era', + 'Era Farming', + 'Era novel', + 'Everyday Life', + 'Ex-Lovers', + 'Face-Slapping', + 'Face-Slapping Drama', + 'Face-Slapping Fiction', + 'Fake Daughter', + 'Fake Marriage', + 'fake vs. real', + 'fake vs. real daughter', + 'fake vs. real heiress', + 'Familial Love', + 'Family', + 'family affairs', + 'Family Bonds', + 'Family conflict', + 'Family Doting', + 'Family Drama', + 'Family Life', + 'family matters', + 'Famine Survival', + 'Famine Years', + 'Fanfiction', + "Fanfiction (BL - Boys' Love/Yuri)", + 'Fantasy', + 'Fantasy Romance', + 'Farming', + 'Farming life', + 'Female Dominated', + 'Female Lead', + 'Female Protagonist', + 'Flash Marriage', + 'Flirtatious Beauty', + 'Food', + 'Forced Love', + 'Forced Marriage', + 'Forced Marriage & Possession', + 'forced plunder', + 'Fortune', + 'Frail Heroine', + 'FREE NOVEL‼️', + 'Friendship', + 'funny light', + 'Funny MC', + 'Game', + 'Game World', + 'Gender Bender', + 'General', + 'Getting Rich', + 'Ghost', + 'Girls Love', + 'Golden Finger', + 'Gourmet Food', + 'Group Favorite', + 'Group Pampering', + 'Growth System', + 'handsome male lead', + 'Harem', + 'HE', + 'Healing', + 'Heartwarming', + 'Heartwarming Daily Life', + 'Heaven’s Chosen', + 'Hidden Identity', + 'Hidden Marriage', + 'Historical', + 'Historical Era', + 'Historical Fiction', + 'Historical Romance', + 'Horror', + 'Humor', + 'Industry Elite', + 'Inner Courtyard Schemes', + 'Inspirational', + 'Interstellar', + 'Isekai', + 'Josei', + 'Just Sweetness', + 'large age gap', + 'Lazy', + 'Light Family Feuds', + 'Light Mystery', + 'Light Political Intrigue', + 'light romance', + 'Light-hearted', + 'Lighthearted', + 'Little Black Room', + 'Live Streaming', + 'Livestream', + 'livestreaming', + 'Lost Memory', + 'love', + 'Love After Marriage', + 'Love and Hate', + 'love as a battlefield', + 'love at first sight', + 'Love Later', + 'Love-hate relationship', + 'Lucky Charm', + 'Lucky Koi', + 'Lucky Protagonist', + 'Magic', + 'magical space', + 'male', + 'Male Protagonist', + 'Marriage', + 'Marriage Before Love', + 'Marriage First', + 'Martial Arts', + 'Match Made in Heaven', + 'Matriarchal Society', + 'Mature', + 'Mecha', + 'medical skills', + 'Medicine', + 'Medicine and Poison', + 'Melodrama', + 'Metaphysics', + 'Military', + 'Military Husband', + 'Military Island', + 'Military Life', + 'Military Marriage', + 'Military Romance', + 'Military Wedding', + 'Military Wife', + 'mind reading', + 'Misunderstanding', + 'Misunderstandings', + 'Modern', + 'Modern Day', + 'Modern Fantasy', + 'Modern Romance', + 'Modern/Contemporary', + 'Money Depreciation', + 'Motivational', + 'Mpreg', + 'Multiple Children', + 'Multiple Male Lead', + 'murder', + 'mutant', + 'Mutual Devotion', + 'Mutual Purity', + 'Mystery', + 'mystical face-slapping', + 'Mythical Beasts', + 'Mythology', + 'No CP', + 'No Rekindling of Old Flames', + 'No Schemes', + 'Non-human', + 'Obsessive Gong', + 'Obsessive love', + 'Office', + 'officialdom', + 'Older Love Interests', + 'omegaverse', + 'palace fighting', + 'Palace Struggles', + 'Pampering Wife', + 'Past Life', + 'Perfect Match', + 'Period Fiction', + 'Period Novel', + 'planes', + 'Plot Divergence', + 'Points Mall', + 'Poor Protagonist', + 'poor to powerful', + 'Poor to rich', + 'Popularity', + 'Possessive Love', + 'Possessive Male Lead', + 'Power Couple', + 'Power Fantasy', + 'Powerful Protagonist', + 'pregnancy', + 'Present Life', + 'President ML', + 'princess', + 'Protective Male Lead', + 'Psychological', + 'pursuing love', + 'Quick transmigration', + 'quick wear', + 'raising a baby', + 'Raising Children', + 'Real Daughter', + 'rebellion', + 'Rebirth', + 'Reborn', + 'Redemption', + 'Refreshing Fiction', + 'reincarnation', + 'Remarriage', + 'Reunion', + 'Revenge', + 'Revenge Drama', + 'Rich CEO', + 'Rich Family', + 'Rich President', + 'Rivalry', + 'Romance', + 'Romance of the Republic of China', + 'Romantic Comedy', + 'Royal Family', + 'Royalty', + 'Rural', + 'Rural life', + 'Ruthless Crown Prince', + 'Salted fish', + 'SameSexMarriage', + 'Schemes and Conspiracies', + 'Scheming Female Lead', + 'School Life', + 'Sci-fi', + 'Scum Abuse', + 'Scumbag Husband', + 'Second Chance', + 'Second Marriage', + 'Secret Crush', + 'Secret Identity', + 'Secret Love', + 'sect', + 'Seductive', + 'seinen', + 'Serious Drama', + 'Short Story', + 'Shoujo', + 'Shoujo Ai', + 'Shounen', + 'Shounen Ai', + 'Showbiz', + 'Sickly Beauty Shou', + 'Side Character Rise', + 'Slice', + 'Slice of Life', + 'Slight Magical Ability', + 'Slow Burn', + 'slow romance', + 'Slow-burn Romance', + 'smart couple', + 'Smut', + 'Space', + 'Space Ability', + 'Space Spirit', + 'Special Love', + 'Spirit Demons', + 'spoil', + 'Spoiled', + 'Spoiling Wife', + 'spy', + 'Starry Sky', + 'Stepmother', + 'stockpiling', + 'stolen', + 'Streaming', + 'strong', + 'Strong Female Lead', + 'Strong Love Interest', + 'strong pampering', + 'Student Life', + 'Studying', + 'Supernatural', + 'supporting characters', + 'Supporting Female Character', + 'Survival', + 'Suspense', + 'Swapped Baby', + 'Sweet', + 'Sweet Doting', + 'Sweet Love', + 'Sweet Pampering', + 'sweet pet', + 'Sweet Revenge', + 'Sweet Romance', + 'Sweet Story', + 'SweetNovel', + 'system', + 'System Fantasy', + 'System Transmigration', + 'Taciturn Rugged Man', + 'Thriller', + 'Time Travel', + 'Top-Notch Relatives', + 'Tragedy', + 'Transformation', + 'Transmigration', + 'transmigration into a novel', + 'Transmigration into Books', + 'Transmigration to the 1970s', + 'Traveling through space', + 'Traveling through time', + 'Treasure Appraisal', + 'Underdog Triumph', + 'Uniform Romance', + 'Unlimited Flow', + 'Unrequited Love', + 'Urban', + 'urban life', + 'urban realism', + 'Urban romance', + 'Vampires', + 'Village Life', + 'Villain', + 'war', + 'Weak to Strong', + 'wealthy characters', + 'Wealthy Families', + 'Wealthy Family', + 'Wealthy Male Lead', + 'Wealthy/Powerful Male Lead', + 'White Moonlight', + 'Wife-Chasing Crematorium', + 'Wish Fulfillment Novel', + 'Workplace', + 'Wuxia', + 'Xianxia', + 'Xuanhuan', + 'yandere', + 'Yandere Character', + 'Yandere Male Leads', + 'Yaoi', + 'Younger Love Interest', + 'Youth', + 'Yuri', + 'Zombie', +]; + +function unescapeHtmlText(escaped: string) { + const txt = loadCheerio(`<p>${escaped}</p>`); + return txt.text(); +} + +class ShanghaiFantasyPlugin implements Plugin.PluginBase { + MAX_PAGE_CHAPTERS = 5000; // the highest possible value that WP won't complain about, to minimize API calls + HIDE_LOCKED = false; + FETCH_LOCKED_PRICE = true; + + id = 'shanghaifantasy'; + name = 'Shanghai Fantasy'; + icon = 'src/en/shanghaifantasy/icon.png'; + site = 'https://shanghaifantasy.com'; + version = '1.0.0'; + filters = { + status: { + label: 'Status', + type: FilterTypes.Picker, + value: '', + options: [ + { label: 'All', value: '' }, + { label: 'Completed', value: 'Completed' }, + { label: 'Draft', value: 'Draft' }, + { label: 'Dropped', value: 'Dropped' }, + { label: 'Hiatus', value: 'Hiatus' }, + { label: 'Ongoing', value: 'Ongoing' }, + ], + }, + genres: { + label: 'Genres', + type: FilterTypes.CheckboxGroup, + value: [], + options: TAG_LIST.map(v => ({ label: v, value: v.replace(' ', '+') })), + }, + query: { + label: 'Search Term', + type: FilterTypes.TextInput, + value: '', + }, + } satisfies Filters; + imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined; + + //flag indicates whether access to LocalStorage, SesesionStorage is required. + webStorageUtilized?: boolean; + + async popularNovels( + pageNo: number, + { + showLatestNovels, + filters, + }: Plugin.PopularNovelsOptions<typeof this.filters>, + ): Promise<Plugin.NovelItem[]> { + // NOTE: this website doesn't track read count or scoring, so it doesn't have a "popularity" ranking. + // The function will always return the "latest" ranking of the entire content list. + + const term = + (filters?.genres?.value ?? []).length > 0 + ? filters.genres.value.join('*') + : ''; + const novelstatus = filters?.status?.value ?? ''; + const orderCriterion = ''; // default: date, other: title + const orderDirection = ''; // default: desc, other: asc + const query = filters?.query?.value ?? ''; // also as a filter, because search in LNReader currently doesn't support searching and filtering together + + const url = `${this.site}/wp-json/fiction/v1/novels/?novelstatus=${novelstatus}&term=${term}&page=${pageNo}&orderBy=${orderCriterion}&order=${orderDirection}&query=${query}`; + + const result = await fetchApi(url); + if (!result.ok) { + throw new Error('Captcha error, please open in webview'); + } + + const body: { title: string; permalink: string; novelImage: string }[] = + await result.json(); + + return body.map((item: any) => ({ + name: unescapeHtmlText(item.title), + path: item.permalink, + cover: item.novelImage, + })); + } + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise<Plugin.NovelItem[]> { + const url = `${this.site}/wp-json/fiction/v1/novels/?novelstatus=&term=&page=${pageNo}&orderBy=&order=&query=${searchTerm}`; + + const result = await fetchApi(url); + if (!result.ok) { + throw new Error('Captcha error, please open in webview'); + } + + const body: { title: string; permalink: string; novelImage: string }[] = + await result.json(); + + return body.map((item: any) => ({ + name: unescapeHtmlText(item.title), + path: item.permalink, + cover: item.novelImage, + })); + } + + async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> { + const novel: Plugin.SourceNovel = { + path: novelPath, + name: 'Untitled', + }; + + const url = novelPath; + const result = await fetchApi(url); + if (!result.ok) { + throw new Error('Captcha error, please open in webview'); + } + const $ = loadCheerio(await result.text()); + + const detailsEl = $('div:has(>#likebox)'); + novel.cover = $(detailsEl).find('>img').attr('src') ?? defaultCover; + novel.name = $(detailsEl).find('p.text-lg').text().trim(); + + const statusEl = $(detailsEl).find('>div>a.mx-auto>p'); + novel.status = + { + 'Ongoing': NovelStatus.Ongoing, + 'Completed': NovelStatus.Completed, + 'Hiatus': NovelStatus.OnHiatus, + 'Dropped': NovelStatus.Cancelled, + 'Draft': 'Draft', + }[statusEl.text().trim()] ?? NovelStatus.Unknown; + + const origAuthor = ( + $(detailsEl).find('p:has(span:contains("Author:"))')[0] + .lastChild as any as Text + ).data; + const translator = $(detailsEl) + .find('p:has(span:contains("Translator:"))>a') + .text() + .trim(); + novel.author = `${origAuthor} (Translated by: ${translator})`; + + const tags = []; + tags.push( + ...$(detailsEl) + .find('>div>div.flex>span>a') + .get() + .map(el => $(el).text().trim().replace(',', '_')), + ); + novel.genres = tags.join(', '); + + const synopsisEl = $("div[x-show=activeTab==='Synopsis']"); + novel.summary = synopsisEl.text().trim(); + + const category = $('ul#chapterList').attr('data-cat'); + + let pageNo = 1; + let chPage: { + title: string; + permalink: string; + locked: boolean; + price: number; + }[] = []; + novel.chapters = []; + let chapterNumber = 1; + do { + const url = `${this.site}/wp-json/fiction/v1/chapters?category=${category}&order=asc&page=${pageNo}&per_page=${this.MAX_PAGE_CHAPTERS}`; + const result = await fetchApi(url); + if (!result.ok) { + throw new Error('Captcha error, please open in webview'); + } + chPage = await result.json(); + + for (const ch of chPage) { + let title = ch.title; + if (title.startsWith(novel.name)) { + title = title.slice(novel.name.length).trim(); + } + + if (ch.locked) { + // TODO: if the user is logged in, determine if they own the chapter + + if (this.HIDE_LOCKED) { + continue; + } + + title = '🔒 ' + title; + } + const chapter: Plugin.ChapterItem = { + name: unescapeHtmlText(title), + path: ch.permalink, + // releaseTime: '', // not provided + // chapterNumber: chapterNumber, + }; + chapterNumber++; + novel.chapters.push(chapter); + } + + pageNo++; + } while (chPage.length > 0); + + return novel; + } + async parseChapter(chapterPath: string): Promise<string> { + const url = chapterPath; + const result = await fetchApi(url); + if (!result.ok) { + throw new Error('Captcha error, please open in webview'); + } + const body = await result.text(); + const $ = loadCheerio(body); + + const content = $('div.contenta'); + content.remove('script'); + content.remove('.ai-viewports'); + content.remove('.ai-viewport-1'); + content.remove('.ai-viewport-2'); + content.remove('.ai-viewport-3'); + + if ($('div.mycred-sell-this-wrapper').length > 0) { + let price = null; + // TODO: do we want to do a whole logic thing here to determine the price? + if (this.FETCH_LOCKED_PRICE) { + const workUrl = $(content.next()).find('a:not([rel])').attr('href')!!; + const result = await fetchApi(workUrl); + if (!result.ok) { + throw new Error('Captcha error, please open in webview'); + } + const work$ = loadCheerio(await result.text()); + const workCategory = work$('ul#chapterList').attr('data-cat'); + + let pageNo = 1; + let chPage: { + title: string; + permalink: string; + locked: boolean; + price: number; + }[] = []; + let thisChapter = null; + outer: do { + const url = `${this.site}/wp-json/fiction/v1/chapters?category=${workCategory}&order=asc&page=${pageNo}&per_page=${this.MAX_PAGE_CHAPTERS}`; + const result = await fetchApi(url); + if (!result.ok) { + throw new Error('Captcha error, please open in webview'); + } + chPage = await result.json(); + + for (const ch of chPage) { + if (ch.permalink === chapterPath) { + thisChapter = ch; + break outer; + } + } + + pageNo++; + } while (chPage.length > 0); + + if (thisChapter !== null) { + price = thisChapter.price; + } + } + + const priceMsg = + price !== null + ? `This chapter costs ${price} coins.` + : `To see the exact price, check the novel's landing page in WebView.`; + + return ` + <h3>This chapter is locked.</h3> + <p> + Viewing this chapter before it is publicly unlocked (if ever) requires payment. + If you have an account, use WebView to login and unlock the chapter. + </p> + <p> + ${priceMsg} + </p> + <p> + If you're certain that you are logged in and have unlocked the chapter, and are + receiving this message in error, feel free to post a bug report. + </p> + `; + } + + return content.html()!!; + } + + resolveUrl = (path: string, isNovel?: boolean) => path; +} + +export default new ShanghaiFantasyPlugin(); \ No newline at end of file diff --git a/plugins/multisrc/lightnovelwp/custom/kolnovel/chapterTransform.js b/plugins/multisrc/lightnovelwp/custom/kolnovel/chapterTransform.js new file mode 100644 index 000000000..cc1ff0ecf --- /dev/null +++ b/plugins/multisrc/lightnovelwp/custom/kolnovel/chapterTransform.js @@ -0,0 +1,5 @@ +$('article > style') + .text() + .match(/\\.\\w+(?=\\s*[,{])/g) + ?.forEach(tag => $(`p${tag}`).remove()); +$('.epcontent .code-block').remove(); diff --git a/plugins/multisrc/lightnovelwp/custom/novelsknight/chapterTransform.js b/plugins/multisrc/lightnovelwp/custom/novelsknight/chapterTransform.js new file mode 100644 index 000000000..d5d5b037f --- /dev/null +++ b/plugins/multisrc/lightnovelwp/custom/novelsknight/chapterTransform.js @@ -0,0 +1,11 @@ +$('.announ').remove(); +return ( + $('.epcontent') + .eq(-1) + .find('p') + .map(function (i, el) { + return '<p>' + $(this).text() + '</p>'; + }) + .toArray() + .join('\n') || '' +); diff --git a/plugins/multisrc/lightnovelwp/custom/requiemtls/chapterTransform.js b/plugins/multisrc/lightnovelwp/custom/requiemtls/chapterTransform.js new file mode 100644 index 000000000..3ff6e3ab1 --- /dev/null +++ b/plugins/multisrc/lightnovelwp/custom/requiemtls/chapterTransform.js @@ -0,0 +1,29 @@ +$('div.entry-content script').remove(); + +const url = this.site + chapterPath.slice(0, -1); +const offsets = [ + [0, 12368, 12462], + [1, 6960, 7054], + [2, 4176, 4270], +]; +const idx = (url.length * url.charCodeAt(url.length - 1) * 2) % 3; +const [_, offsetLower, offsetCap] = offsets[idx] ?? offsets[0]; + +const asciiA = 'A'.charCodeAt(0); +const asciiz = 'z'.charCodeAt(0); +$('div.entry-content > p').text((_, txt) => + txt + .split('') + .map(char => { + const code = char.charCodeAt(0); + const offset = + code >= offsetLower + asciiA && code <= offsetLower + asciiz + ? offsetLower + : offsetCap; + const decoded = code - offset; + return decoded >= 32 && decoded <= 126 + ? String.fromCharCode(decoded) + : char; + }) + .join(''), +); diff --git a/plugins/multisrc/lightnovelwp/generator.js b/plugins/multisrc/lightnovelwp/generator.js index d6bcd242e..41da85999 100644 --- a/plugins/multisrc/lightnovelwp/generator.js +++ b/plugins/multisrc/lightnovelwp/generator.js @@ -1,10 +1,14 @@ import list from './sources.json' with { type: 'json' }; import { existsSync, readFileSync } from 'fs'; import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; +import { dirname, join, resolve } from 'path'; const folder = dirname(fileURLToPath(import.meta.url)); +const LightNovelWPTemplate = readFileSync(join(folder, 'template.ts'), { + encoding: 'utf-8', +}); + export const generateAll = function () { return list.map(source => { const exist = existsSync(join(folder, 'filters', source.id + '.json')); @@ -15,22 +19,23 @@ export const generateAll = function () { source.filters = JSON.parse(filters).filters; } console.log( - `[lightnovelwp] Generating: ${source.id}${' '.repeat(20 - source.id.length)} ${source.filters ? '🔎with filters🔍' : '🚫no filters🚫'}`, + `[lightnovelwp] Generating: ${source.id} ${' '.repeat(20 - source.id.length)} ${source.filters ? '🔎with filters🔍' : '🚫no filters🚫'}`, ); return generator(source); }); }; const generator = function generator(source) { - const LightNovelWPTemplate = readFileSync(join(folder, 'template.ts'), { - encoding: 'utf-8', - }); + const chapterTransformJsOrPath = source.options?.customJs?.chapterTransform; + const chapterTransformPath = chapterTransformJsOrPath + ? join(folder, chapterTransformJsOrPath) + : ''; + const chapterTransformJs = existsSync(chapterTransformPath) + ? readFileSync(chapterTransformPath, { encoding: 'utf-8' }) + : chapterTransformJsOrPath; const pluginScript = ` -${LightNovelWPTemplate.replace( - '// CustomJS HERE', - source.options?.customJs || '', -)} +${LightNovelWPTemplate.replace('// CustomJS HERE', chapterTransformJs || '')} const plugin = new LightNovelWPPlugin(${JSON.stringify(source)}); export default plugin; `.trim(); diff --git a/plugins/multisrc/lightnovelwp/sources.json b/plugins/multisrc/lightnovelwp/sources.json index 656ace4e0..fa248857c 100644 --- a/plugins/multisrc/lightnovelwp/sources.json +++ b/plugins/multisrc/lightnovelwp/sources.json @@ -15,7 +15,9 @@ "options": { "lang": "Arabic", "reverseChapters": true, - "customJs": "$('article > style').text().match(/\\.\\w+(?=\\s*[,{])/g)?.forEach(tag => $(`p${tag}`).remove());$('.epcontent .code-block').remove();", + "customJs": { + "chapterTransform": "custom/kolnovel/chapterTransform.js" + }, "versionIncrements": 9 } }, @@ -107,7 +109,9 @@ "lang": "English", "reverseChapters": true, "versionIncrements": 2, - "customJs": "$('.announ').remove(); return $('.epcontent').eq(-1).find('p').map(function (i, el) { return '<p>' + $(this).text() + '</p>'; }).toArray().join('\\n') || '';" + "customJs": { + "chapterTransform": "custom/novelsknight/chapterTransform.js" + } } }, { @@ -199,7 +203,9 @@ "options": { "lang": "English", "reverseChapters": true, - "customJs": "\n $('div.entry-content script').remove();\n\n const url = this.site + chapterPath.slice(0, -1);\n const offsets = [[0, 12368, 12462], [1, 6960, 7054], [2, 4176, 4270]];\n const idx = url.length * url.charCodeAt(url.length - 1) * 2 % 3;\n const [_, offsetLower, offsetCap] = offsets[idx] ?? offsets[0];\n\n const asciiA = 'A'.charCodeAt(0);\n const asciiz = 'z'.charCodeAt(0);\n\n $('div.entry-content > p').text((_, txt) =>\n txt.split('').map(char => {\n const code = char.charCodeAt(0);\n const offset = (code >= offsetLower + asciiA && code <= offsetLower + asciiz)\n ? offsetLower\n : offsetCap;\n const decoded = code - offset;\n return (decoded >= 32 && decoded <= 126) ? String.fromCharCode(decoded) : char;\n }).join('')\n );\n", + "customJs": { + "chapterTransform": "custom/requiemtls/chapterTransform.js" + }, "versionIncrements": 4 } }, @@ -284,5 +290,21 @@ "lang": "English", "reverseChapters": true } + }, + { + "id": "katreadingarchive", + "sourceSite": "https://katreadingarchive.me", + "sourceName": "Kat Reading Archive", + "options": { + "lang": "English", + "versionIncrements": 1, + "reverseChapters": true, + "hasLocked": true, + "customJs": { + "chapterTransform": "$('div.epcontent > span:first-of-type').remove(); $('.ckb-wrap').remove();", + "runtime": "multisrc/lightnovelwp/katreadingarchive/customJs.js" + }, + "customCss": "multisrc/lightnovelwp/katreadingarchive/customCss.css" + } } ] diff --git a/plugins/multisrc/lightnovelwp/template.ts b/plugins/multisrc/lightnovelwp/template.ts index a51f23915..93bfb2f03 100644 --- a/plugins/multisrc/lightnovelwp/template.ts +++ b/plugins/multisrc/lightnovelwp/template.ts @@ -12,8 +12,12 @@ type LightNovelWPOptions = { lang?: string; versionIncrements?: number; seriesPath?: string; - customJs?: string; + customJs?: { + runtime?: string; + chapterTransform?: string; + }; hasLocked?: boolean; + customCss?: string; }; export type LightNovelWPMetadata = { @@ -32,7 +36,10 @@ class LightNovelWPPlugin implements Plugin.PluginBase { version: string; options?: LightNovelWPOptions; filters?: Filters; + customJS?: string; + customCSS?: string; + // TODO: this probably needs to be done at use time, otherwise changes won't be reflected hideLocked = storage.get('hideLocked'); pluginSettings?: Record<string, any>; @@ -42,9 +49,11 @@ class LightNovelWPPlugin implements Plugin.PluginBase { this.icon = `multisrc/lightnovelwp/${metadata.id.toLowerCase()}/icon.png`; this.site = metadata.sourceSite; const versionIncrements = metadata.options?.versionIncrements || 0; - this.version = `1.1.${9 + versionIncrements}`; + this.version = `1.1.${10 + versionIncrements}`; this.options = metadata.options ?? ({} as LightNovelWPOptions); this.filters = metadata.filters satisfies Filters; + this.customJS = this.options.customJs?.runtime; + this.customCSS = this.options.customCss; if (this.options?.hasLocked) { this.pluginSettings = { @@ -64,7 +73,7 @@ class LightNovelWPPlugin implements Plugin.PluginBase { return url_parts.join('.'); } - async safeFecth(url: string, search: boolean): Promise<string> { + async safeFetch(url: string, search: boolean): Promise<string> { const urlParts = url.split('://'); const protocol = urlParts.shift(); const sanitizedUri = urlParts[0].replace(/\/\//g, '/'); @@ -147,13 +156,13 @@ class LightNovelWPPlugin implements Plugin.PluginBase { url += `&${key}=${value}`; else if (filters[key].value) url += `&${key}=${filters[key].value}`; } - const html = await this.safeFecth(url, false); + const html = await this.safeFetch(url, false); return this.parseNovels(html); } async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> { const baseURL = this.site; - const html = await this.safeFecth(baseURL + novelPath, false); + const html = await this.safeFetch(baseURL + novelPath, false); const novel: Plugin.SourceNovel = { path: novelPath, @@ -180,7 +189,10 @@ class LightNovelWPPlugin implements Plugin.PluginBase { let hasLockItemOnChapterNum = false; const chapters: Plugin.ChapterItem[] = []; let tempChapter = {} as Plugin.ChapterItem; - const hideLocked = this.hideLocked; + + const hideLocked = !!( + storage.get('hideLocked') ?? this.pluginSettings?.['hideLocked'].value + ); const parser = new Parser({ onopentag(name, attribs) { @@ -319,7 +331,7 @@ class LightNovelWPPlugin implements Plugin.PluginBase { } else if (hasLockItemOnChapterNum) { isPaidChapter = false; } - extractChapterNumber(data, tempChapter); + extractChapterNumber(data.replace('🔒', '').trim(), tempChapter); } else if (isReadingChapterInfo === 2) { tempChapter.name = data @@ -421,29 +433,28 @@ class LightNovelWPPlugin implements Plugin.PluginBase { novel.chapters = chapters; } - novel.summary = novel.summary.trim(); + novel.summary = novel.summary?.trim(); return novel; } async parseChapter(chapterPath: string): Promise<string> { - let data = await this.safeFecth(this.site + chapterPath, false); - if (this.options?.customJs) { + let data = await this.safeFetch(this.site + chapterPath, false); + const $ = load(data); + if (this.options?.customJs?.chapterTransform) { try { - const $ = load(data); // CustomJS HERE - data = $.html(); } catch (error) { console.error('Error executing customJs:', error); throw error; } } - return ( - data - .match(/<div.*?class="epcontent ([^]*?)<div.*?class="?bottomnav/g)?.[0] - .match(/<p[^>]*>([^]*?)<\/p>/g) - ?.join('\n') || '' - ); + const content = $('div.epcontent'); + // content.find('h1:first-child').remove(); + + // content.find('> :not(p)').remove(); + + return content.html() ?? ''; } async searchNovels( @@ -452,7 +463,7 @@ class LightNovelWPPlugin implements Plugin.PluginBase { ): Promise<Plugin.NovelItem[]> { const url = this.site + 'page/' + page + '/?s=' + encodeURIComponent(searchTerm); - const html = await this.safeFecth(url, true); + const html = await this.safeFetch(url, true); return this.parseNovels(html); } } diff --git a/public/static/multisrc/lightnovelwp/ellotl/icon.png b/public/static/multisrc/lightnovelwp/ellotl/icon.png index 93b22401c..3d8186579 100644 Binary files a/public/static/multisrc/lightnovelwp/ellotl/icon.png and b/public/static/multisrc/lightnovelwp/ellotl/icon.png differ diff --git a/public/static/multisrc/lightnovelwp/katreadingarchive/customCss.css b/public/static/multisrc/lightnovelwp/katreadingarchive/customCss.css new file mode 100644 index 000000000..d4d604d4d --- /dev/null +++ b/public/static/multisrc/lightnovelwp/katreadingarchive/customCss.css @@ -0,0 +1,20 @@ +blockquote, q { + padding: 5px 10px; + background-color: var(--theme-surfaceVariant); + color: var(--theme-onSurfaceVariant); + border-left: 4px solid var(--theme-outline); + quotes: none; +} + +span.modern-footnotes-footnote__note { + display: none; + font-size: 80%; + margin: 1em; + padding: 1em; + background: var(--theme-surfaceVariant); + color: var(--theme-onSurfaceVariant); +} + +span.modern-footnotes-footnote__note.active { + display: block; +} \ No newline at end of file diff --git a/public/static/multisrc/lightnovelwp/katreadingarchive/customJs.js b/public/static/multisrc/lightnovelwp/katreadingarchive/customJs.js new file mode 100644 index 000000000..d62a0baca --- /dev/null +++ b/public/static/multisrc/lightnovelwp/katreadingarchive/customJs.js @@ -0,0 +1,9 @@ +document.querySelectorAll('sup[data-mfn]').forEach(el => { + const targetEl = `mfn-content-${el.getAttribute('data-mfn-post-scope')}-${el.getAttribute('data-mfn')}`; + el.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + + document.querySelector(`#${targetEl}`).classList.toggle('active'); + }); +}); diff --git a/public/static/multisrc/lightnovelwp/katreadingarchive/icon.png b/public/static/multisrc/lightnovelwp/katreadingarchive/icon.png new file mode 100644 index 000000000..e35d7630a Binary files /dev/null and b/public/static/multisrc/lightnovelwp/katreadingarchive/icon.png differ diff --git a/public/static/multisrc/lightnovelwp/noveltr/icon.png b/public/static/multisrc/lightnovelwp/noveltr/icon.png index 76a7ccd5a..80eac7542 100644 Binary files a/public/static/multisrc/lightnovelwp/noveltr/icon.png and b/public/static/multisrc/lightnovelwp/noveltr/icon.png differ diff --git a/public/static/multisrc/madara/dragonholic/icon.png b/public/static/multisrc/madara/dragonholic/icon.png index 7d75e92bc..773e5c796 100644 Binary files a/public/static/multisrc/madara/dragonholic/icon.png and b/public/static/multisrc/madara/dragonholic/icon.png differ diff --git a/public/static/multisrc/madara/webnoveltraslation/icon.png b/public/static/multisrc/madara/webnoveltraslation/icon.png new file mode 100644 index 000000000..42f910cba Binary files /dev/null and b/public/static/multisrc/madara/webnoveltraslation/icon.png differ diff --git a/public/static/src/en/kdtnovels/icon.png b/public/static/src/en/kdtnovels/icon.png index b962d7edd..c45344eeb 100644 Binary files a/public/static/src/en/kdtnovels/icon.png and b/public/static/src/en/kdtnovels/icon.png differ diff --git a/public/static/src/en/mznovels/customCss.css b/public/static/src/en/mznovels/customCss.css new file mode 100644 index 000000000..e76519210 --- /dev/null +++ b/public/static/src/en/mznovels/customCss.css @@ -0,0 +1,64 @@ + +/* Author Feedback */ +.author-feedback { + position: relative; + cursor: help; + border-bottom: 1px dashed var(--theme-primary); + color: var(--theme-primary); +} + +.author-feedback:hover::after, +.author-feedback.active::after { + content: attr(data-note); + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + background: var(--theme-surface); + color: var(--theme-onSurface); + padding: 8px 12px; + border-radius: 4px; + font-size: 0.9em; + max-width: 300px; + width: max-content; + white-space: pre-line; + overflow-wrap: break-word; + word-break: break-word; + word-wrap: break-word; + z-index: 999; + box-shadow: var(--theme-outline); + pointer-events: none; +} + +.author-feedback.active::after { + display: block; +} + + +blockquote.author_note { + padding: 5px 10px; + background-color: var(--theme-surfaceVariant); + color: var(--theme-onSurfaceVariant); + border-left: 4px solid var(--theme-outline); + quotes: none; +} +blockquote.author_note > p { + white-space-collapse: preserve-breaks; +} + +.anchor { + position: relative; +} +.anchor > span { + position: absolute; + bottom: 40vh; + visibility: hidden; + pointer-events: none; +} +.footnotes { + display: grid; + grid-template-columns: auto auto; + row-gap: 0.4em; + column-gap: 0.6em; + justify-content: start; +} \ No newline at end of file diff --git a/public/static/src/en/mznovels/customJs.js b/public/static/src/en/mznovels/customJs.js new file mode 100644 index 000000000..a01b019b6 --- /dev/null +++ b/public/static/src/en/mznovels/customJs.js @@ -0,0 +1,34 @@ +document.querySelectorAll('.author-feedback').forEach( + el => + (el.onclick = function (e) { + e.preventDefault(); + e.stopPropagation(); + this.classList.add('active'); + }), +); + +document.addEventListener('click', e => { + const els = document.querySelectorAll('.author-feedback.active'); + let anyClosed = false; + for (const el of els) { + if (!el.contains(e.target)) { + el.classList.remove('active'); + anyClosed = true; + } + } + if (anyClosed) { + e.preventDefault(); + e.stopPropagation(); + } +}); + +document.querySelectorAll('a[href^="#"]').forEach(el => { + el.addEventListener('click', e => { + // e.preventDefault(); + e.stopPropagation(); + // const target = document.getElementById(el.getAttribute('href').slice(1)); + // if (target) { + // target.scrollIntoView({ behavior: 'smooth' }); + // } + }); +}); \ No newline at end of file diff --git a/public/static/src/en/mznovels/icon.png b/public/static/src/en/mznovels/icon.png new file mode 100644 index 000000000..fa3cf09cc Binary files /dev/null and b/public/static/src/en/mznovels/icon.png differ diff --git a/public/static/src/en/relibrary/customCss.css b/public/static/src/en/relibrary/customCss.css new file mode 100644 index 000000000..f37df5010 --- /dev/null +++ b/public/static/src/en/relibrary/customCss.css @@ -0,0 +1,188 @@ +hr { + border: 1px solid; +} + +/* Forum */ +.forum { + border-color: var(--theme-primary); + border-left-width: 3px; + border-left-style: solid; + padding-left: 15px; +} +.thread { + font-size: 16px !important; + color: var(--theme-onPrimary); + letter-spacing: 0.5px; + font-family: 'NTR'; + margin-bottom: 13px; +} +.reply { + padding-left: 40px; +} + +/* Email */ +.email { + border: 3px dotted var(--theme-outline); + padding: 10px; + margin-bottom: 1em; +} +.message { + font-family: "Rounded-X M+ 1m regular"; + font-size: 20px !important; + line-height: 25px !important; +} + +/*Live Comment */ +.live-comment { + flex-direction: column; + align-items: flex-end; + justify-content: center; + display: flex; + gap: 0.5em; + margin-bottom: 1em; +} +.live-comment-left { + flex-direction: column; + align-items: flex; + justify-content: center; + display: flex; + margin: 0.5em; +} +.chat { + background: var(--theme-primary); + font-size: 15px !important; + line-height: 16.5px !important; + padding: 1.25em 2.375em; + margin: 0; + color: var(--theme-onPrimary) !important; + max-width: 60%; +} +.chat-fuwari { + background: var(--theme-secondary); + font-size: 15px !important; + line-height: 16.5px !important; + padding: 1.25em 2.375em; + margin: 0; + color: var(--theme-onSecondary) !important; + max-width: 60%; +} +.chat-left { + background: var(--theme-primary); + font-size: 15px !important; + line-height: 16.5px !important; + padding: 1.25em 2.375em; + margin: 0.3em; + color: var(--theme-onPrimary) !important; + width: 50%; +} +.chat-fuwari-left { + background: var(--theme-secondary); + font-size: 15px !important; + line-height: 16.5px !important; + padding: 1.25em 2.375em; + margin: 0.3em; + color: var(--theme-onSecondary) !important; + width: 50%; +} +.chat-yuru-left { + background: var(--theme-tertiary); + font-size: 15px !important; + line-height: 16.5px !important; + padding: 1.25em 2.375em; + margin: 0.3em; + color: var(--theme-onTertiary) !important; + width: 50%; +} +/* Superchat */ +.custom-block-wrapper { + margin-bottom: 12px; + margin-top: 12px; + display: flex; + justify-content: flex-end; + align-items: flex-end; + width: 100%; + text-align: right; +} +.custom-block { + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: flex-end; + width: 300px; +} +.custom-block .upper { + color: #fff; + padding: 12px 16px; + flex: 1; + text-align: right; + border-top-left-radius: 10px; + border-top-right-radius: 10px; + width: 100%; + min-width: 0; +} +.custom-block .lower { + color: #fff; + padding: 12px 16px; + flex: 1; + text-align: right; + border-bottom-left-radius: 10px; + border-bottom-right-radius: 10px; + width: 100%; + min-width: 0; +} +.superchat-pink-upper { + background-color: #c2185b; +} +.superchat-pink-lower { + background-color: #e91e63; +} +.superchat-blue-upper { + background-color: #00b8d4; +} +.superchat-blue-lower { + background-color: #00e5ff; +} +.superchat-yellow-upper { + background-color: #ffb300; +} +.superchat-yellow-lower { + background-color: #ffca28; +} +.superchat-orange-upper { + background-color: #e65100; +} +.superchat-orange-lower { + background-color: #f57c00; +} +.superchat-red-upper { + background-color: #d00000; +} +.superchat-red-lower { +background-color: #e62117; +} + +/*Rounded Table*/ +.rounded { + border-radius: 15px; +} + +/* Center */ +.center { + text-align: center; +} + +.imgcenter { + display: block; + margin-left: auto; + margin-right: auto; +} + + +.easy-footnote-to-top { + display: inline-block; + margin-left: 10px; +} +.easy-footnote-to-top:after { + content: "\2191"; + line-height: 1; +} \ No newline at end of file diff --git a/public/static/src/en/shanghaifantasy/icon.png b/public/static/src/en/shanghaifantasy/icon.png new file mode 100644 index 000000000..124863d6b Binary files /dev/null and b/public/static/src/en/shanghaifantasy/icon.png differ diff --git a/public/static/src/ru/novelovh/icon.png b/public/static/src/ru/novelovh/icon.png index 1636753c3..cab4f0c9f 100644 Binary files a/public/static/src/ru/novelovh/icon.png and b/public/static/src/ru/novelovh/icon.png differ diff --git a/src/types/plugin.ts b/src/types/plugin.ts index 477a01734..c25a9402b 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -58,6 +58,8 @@ export namespace Plugin { body?: string; }; + export type PluginSettings = Filters; + export type PluginBase = { id: string; name: string; @@ -74,6 +76,7 @@ export namespace Plugin { imageRequestInit?: ImageRequestInit; filters?: Filters; version: string; + pluginSettings?: PluginSettings; //flag indicates whether access to LocalStorage, SesesionStorage is required. webStorageUtilized?: boolean; popularNovels(