From 7a3f1c5b3d5ddd3a736815082ecb69d3e52c588a Mon Sep 17 00:00:00 2001 From: MareDevi Date: Wed, 27 Nov 2024 09:56:04 +0800 Subject: [PATCH] feat: add basic traditional chinese support for linovelib (#1335) * add basic traditional chinese support for linovelib, resloves #954 #744 * change plugin id back --- src/plugins/chinese/linovelib_tw.ts | 546 ++++++++++++++++++++++++++++ 1 file changed, 546 insertions(+) create mode 100644 src/plugins/chinese/linovelib_tw.ts diff --git a/src/plugins/chinese/linovelib_tw.ts b/src/plugins/chinese/linovelib_tw.ts new file mode 100644 index 0000000..b40f2b7 --- /dev/null +++ b/src/plugins/chinese/linovelib_tw.ts @@ -0,0 +1,546 @@ +import { CheerioAPI, load as parseHTML } from 'cheerio'; +import { fetchText } from '@libs/fetch'; +import { FilterTypes, Filters } from '@libs/filterInputs'; +import { Plugin } from '@typings/plugin'; +import { NovelStatus } from '@libs/novelStatus'; + +class Linovelib_tw implements Plugin.PluginBase { + id = 'linovelib_tw'; + name = 'Linovelib(繁體)'; + icon = 'src/cn/linovelib/icon.png'; + site = 'https://tw.linovelib.com/'; + version = '1.0.0'; + + async popularNovels( + pageNo: number, + { + showLatestNovels, + filters, + }: Plugin.PopularNovelsOptions, + ): Promise { + const rank = showLatestNovels ? 'lastupdate' : filters.rank.value; + const url = `${this.site}/top/${rank}/${pageNo}.html`; + + const body = await fetchText(url); + if (body === '') throw Error('無法獲取小説列表, 請檢查網絡'); + + const loadedCheerio = parseHTML(body); + + const novels: Plugin.NovelItem[] = []; + + loadedCheerio('.module-rank-booklist .book-layout').each((i, el) => { + const url = loadedCheerio(el).attr('href'); + + const novelName = loadedCheerio(el).find('.book-title').text(); + const novelCover = loadedCheerio(el) + .find('div.book-cover > img') + .attr('data-src'); + if (!url) return; + + const novel = { + name: novelName, + cover: novelCover, + path: url, + }; + + novels.push(novel); + }); + + return novels; + } + + async parseNovel(novelPath: string): Promise { + const url = this.site + novelPath; + + const body = await fetchText(url); + if (body === '') throw Error('無法獲取小説信息, 請檢查網絡'); + + const loadedCheerio = parseHTML(body); + + const novel: Plugin.SourceNovel = { + path: novelPath, + chapters: [], + name: loadedCheerio('#bookDetailWrapper .book-title').text(), + }; + + novel.cover = loadedCheerio('#bookDetailWrapper img.book-cover').attr( + 'src', + ); + + novel.summary = loadedCheerio('#bookSummary content').text(); + + novel.author = loadedCheerio('#bookDetailWrapper .book-rand-a a').text(); + + const meta = loadedCheerio('#bookDetailWrapper .book-meta').text(); + novel.status = meta.includes('完結') + ? NovelStatus.Completed + : NovelStatus.Ongoing; + + novel.genres = loadedCheerio('.tag-small.red') + .children('a') + .map((i, el) => loadedCheerio(el).text()) + .toArray() + .join(','); + + // Table of Content is on a different page than the summary page + const chapter: Plugin.ChapterItem[] = []; + + const idPattern = /\/(\d+)\.html/; + const novelId = url.match(idPattern)?.[1]; + + const chaptersUrl = this.site + loadedCheerio('#btnReadBook').attr('href'); + const chaptersBody = await fetchText(chaptersUrl); + + const chaptersLoadedCheerio = parseHTML(chaptersBody); + + let volumeName: string, chapterId: number; + + chaptersLoadedCheerio('#volumes .chapter-li:not(.volume-cover)').each( + (i, el) => { + if (chaptersLoadedCheerio(el).hasClass('chapter-bar')) { + volumeName = chaptersLoadedCheerio(el).text(); + return; + } else { + const urlPart = chaptersLoadedCheerio(el) + .find('.chapter-li-a') + .attr('href'); + const chapterIdMatch = urlPart?.match(idPattern); + + // Sometimes the href attribute does not contain the url, but javascript:cid(0). + // Increment the previous chapter ID should result in the right URL + if (chapterIdMatch) { + chapterId = +chapterIdMatch[1]; + } else { + chapterId++; + } + } + + const chapterUrl = `/novel/${novelId}/${chapterId}.html`; + const chapterName = + volumeName + + ' — ' + + chaptersLoadedCheerio(el).find('.chapter-index').text().trim(); + const releaseDate = null; + + if (!chapterId) return; + + chapter.push({ + name: chapterName, + releaseTime: releaseDate, + path: chapterUrl, + }); + }, + ); + + novel.chapters = chapter; + + return novel; + } + + async parseChapter(chapterPath: string): Promise { + let chapterName, + chapterText = '', + hasNextPage, + pageHasNextPage, + pageText = ''; + let pageNumber = 1; + + /* + * TODO: Maybe there are other ways to get the translation table + * It is embed and encrypted inside readtool.js + * UPDATE: Decrypted, see skillgg + */ + // const mapping_dict = { + // '“': '「', + // '’': '』', + // '': '是', + // '': '不', + // '': '好', + // '': '个', + // '': '开', + // '': '样', + // '': '想', + // '': '说', + // '': '年', + // '': '那', + // '': '她', + // '': '美', + // '': '自', + // '': '家', + // '': '而', + // '': '去', + // '': '都', + // '': '于', + // '': '舔', + // '': '他', + // '': '只', + // '': '看', + // '': '来', + // '': '用', + // '': '道', + // '': '得', + // '': '乳', + // '': '茎', + // '': '肉', + // '': '胸', + // '': '淫', + // '': '性', + // '': '骚', + // '”': '」', + // '': '的', + // '': '当', + // '': '人', + // '': '有', + // '': '上', + // '': '到', + // '': '地', + // '': '中', + // '': '生', + // '': '着', + // '': '和', + // '': '起', + // '': '交', + // '': '以', + // '': '可', + // '': '过', + // '': '能', + // '': '多', + // '': '心', + // '': '小', + // '': '成', + // '': '了', + // '': '把', + // '': '发', + // '': '第', + // '': '子', + // '': '事', + // '': '阴', + // '': '欲', + // '': '里', + // '': '私', + // '': '臀', + // '': '脱', + // '': '唇', + // '‘': '『', + // '': '一', + // '': '我', + // '': '在', + // '': '这', + // '': '们', + // '': '时', + // '': '为', + // '': '你', + // '': '国', + // '': '就', + // '': '要', + // '': '也', + // '': '后', + // '': '没', + // '': '下', + // '': '天', + // '': '对', + // '': '然', + // '': '学', + // '': '之', + // '': '出', + // '': '没', + // '': '如', + // '': '还', + // '': '大', + // '': '作', + // '': '种', + // '': '液', + // '': '呻', + // '': '射', + // '': '穴', + // '': '么', + // '': '裸', + // }; + const skillgg: Record = { + '\u201c': '\u300c', + '\u201d': '\u300d', + '\u2018': '\u300e', + '\u2019': '\u300f', + '\ue82c': '\u7684', + '\ue852': '\u4e00', + '\ue82d': '\u662f', + '\ue819': '\u4e86', + '\ue856': '\u6211', + '\ue857': '\u4e0d', + '\ue816': '\u4eba', + '\ue83c': '\u5728', + '\ue830': '\u4ed6', + '\ue82e': '\u6709', + '\ue836': '\u8fd9', + '\ue859': '\u4e2a', + '\ue80a': '\u4e0a', + '\ue855': '\u4eec', + '\ue842': '\u6765', + '\ue858': '\u5230', + '\ue80b': '\u65f6', + '\ue81f': '\u5927', + '\ue84a': '\u5730', + '\ue853': '\u4e3a', + '\ue81e': '\u5b50', + '\ue822': '\u4e2d', + '\ue813': '\u4f60', + '\ue85b': '\u8bf4', + '\ue807': '\u751f', + '\ue818': '\u56fd', + '\ue810': '\u5e74', + '\ue812': '\u7740', + '\ue851': '\u5c31', + '\ue801': '\u90a3', + '\ue80c': '\u548c', + '\ue815': '\u8981', + '\ue84c': '\u5979', + '\ue840': '\u51fa', + '\ue848': '\u4e5f', + '\ue835': '\u5f97', + '\ue800': '\u91cc', + '\ue826': '\u540e', + '\ue863': '\u81ea', + '\ue861': '\u4ee5', + '\ue854': '\u4f1a', + '\ue827': '\u5bb6', + '\ue83b': '\u53ef', + '\ue85d': '\u4e0b', + '\ue84d': '\u800c', + '\ue862': '\u8fc7', + '\ue81c': '\u5929', + '\ue81d': '\u53bb', + '\ue860': '\u80fd', + '\ue843': '\u5bf9', + '\ue82f': '\u5c0f', + '\ue802': '\u591a', + '\ue831': '\u7136', + '\ue84b': '\u4e8e', + '\ue837': '\u5fc3', + '\ue829': '\u5b66', + '\ue85e': '\u4e48', + '\ue83a': '\u4e4b', + '\ue832': '\u90fd', + '\ue808': '\u597d', + '\ue841': '\u770b', + '\ue821': '\u8d77', + '\ue845': '\u53d1', + '\ue803': '\u5f53', + '\ue828': '\u6ca1', + '\ue81b': '\u6210', + '\ue83e': '\u53ea', + '\ue820': '\u5982', + '\ue84e': '\u4e8b', + '\ue85a': '\u628a', + '\ue806': '\u8fd8', + '\ue83f': '\u7528', + '\ue833': '\u7b2c', + '\ue811': '\u6837', + '\ue804': '\u9053', + '\ue814': '\u60f3', + '\ue80f': '\u4f5c', + '\ue84f': '\u79cd', + '\ue80e': '\u5f00', + '\ue823': '\u7f8e', + '\ue849': '\u4e73', + '\ue805': '\u9634', + '\ue809': '\u6db2', + '\ue81a': '\u830e', + '\ue844': '\u6b32', + '\ue847': '\u547b', + '\ue850': '\u8089', + '\ue824': '\u4ea4', + '\ue85f': '\u6027', + '\ue817': '\u80f8', + '\ue85c': '\u79c1', + '\ue838': '\u7a74', + '\ue82a': '\u6deb', + '\ue83d': '\u81c0', + '\ue82b': '\u8214', + '\ue80d': '\u5c04', + '\ue839': '\u8131', + '\ue834': '\u88f8', + '\ue846': '\u9a9a', + '\ue825': '\u5507', + }; + const addPage = async (pageCheerio: CheerioAPI) => { + const formatPage = async () => { + // Remove JS and notice of the website + pageCheerio('div.cgo, center').remove(); + + // Load lazyloaded images + pageCheerio('#acontentl img.imagecontent').each((i, el) => { + // Sometimes images are either in data-src or src + const imgSrc = + pageCheerio(el).attr('data-src') || pageCheerio(el).attr('src'); + if (imgSrc) { + // Clean up img element + pageCheerio(el) + .attr('src', imgSrc) + .removeAttr('data-src') + .removeClass('lazyload'); + } + }); + + // Recover the original character + pageText = pageCheerio('#acontentl').html() || ''; + pageText = pageText.replace(/./g, char => skillgg[char] || char); + + return Promise.resolve(); + }; + + await formatPage(); + chapterName = + pageCheerio('#atitle + h3').text() + + ' — ' + + pageCheerio('#atitle').text(); + if (chapterText === '') { + chapterText = '

' + chapterName + '

'; + } + chapterText += pageText; + }; + + const loadPage = async (url: string) => { + const headers = { + 'Accept': + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + 'Accept-Language': + 'zh-CN,zh;q=0.9,zh-TW;q=0.8,zh-HK;q=0.7,en;q=0.6,en-GB;q=0.5,en-US;q=0.4', + 'Cache-Control': 'no-cache', + }; + + const body = await fetchText(url, { headers }); + const pageCheerio = parseHTML(body); + await addPage(pageCheerio); + pageHasNextPage = + pageCheerio('#footlink a:last').text() === '下一页' || + pageCheerio('#footlink a:last').text() === '下一頁' + ? true + : false; + return { pageCheerio, pageHasNextPage }; + }; + + let url = this.site + chapterPath; + const baseUrl = url; + do { + const page = await loadPage(url); + hasNextPage = page.pageHasNextPage; + if (hasNextPage === true) { + pageNumber++; + url = baseUrl.replace(/\.html/gi, `_${pageNumber}` + '.html'); + } + } while (hasNextPage === true); + return chapterText; + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + const url = `${this.site}/search/${encodeURI(searchTerm)}_${pageNo}.html`; + + const body = await fetchText(url); + if (body === '') throw Error('無法獲取搜索結果, 請檢查網絡'); + + const pageCheerio = parseHTML(body); + + const novels: Plugin.NovelItem[] = []; + + // const addPage = async (pageCheerio: CheerioAPI, redirect: string) => { + // const loadSearchResults = () => { + // pageCheerio(".book-ol .book-layout").each((i, el) => { + // let nUrl = pageCheerio(el).attr("href"); + + // const novelName = pageCheerio(el) + // .find(".book-title") + // .text(); + // const novelCover = pageCheerio(el) + // .find("div.book-cover > img") + // .attr("data-src"); + // const novelUrl = this.site + nUrl; + + // if (!nUrl) return; + + // novels.push({ + // name: novelName, + // url: novelUrl, + // cover: novelCover, + // }); + // }); + // }; + + // const novelResults = pageCheerio(".book-ol a.book-layout"); + // if (novelResults.length === 0) { + // } else { + // loadSearchResults(); + // } + + // if (redirect.length) { + // novels.length = 0; + // const novelName = pageCheerio( + // "#bookDetailWrapper .book-title" + // ).text(); + + // const novelCover = pageCheerio( + // "#bookDetailWrapper div.book-cover > img" + // ).attr("src"); + // const novelUrl = + // this.site + + // pageCheerio("#btnReadBook").attr("href")?.slice(0, -8) + + // ".html"; + // novels.push({ + // name: novelName, + // url: novelUrl, + // cover: novelCover, + // }); + // } + // }; + + // NOTE: don't know redirect is for what, comment out for now + + // const redirect = pageCheerio("div.book-layout").text(); + // await addPage(pageCheerio, redirect); + + pageCheerio('.book-ol .book-layout').each((i, el) => { + const nUrl = pageCheerio(el).attr('href'); + + const novelName = pageCheerio(el).find('.book-title').text(); + const novelCover = pageCheerio(el) + .find('div.book-cover > img') + .attr('data-src'); + + if (!nUrl) return; + + novels.push({ + name: novelName, + path: nUrl, + cover: novelCover, + }); + }); + + return novels; + } + + filters = { + rank: { + label: '排行榜', + value: 'monthvisit', + options: [ + { label: '月點擊榜', value: 'monthvisit' }, + { label: '周點擊榜', value: 'weekvisit' }, + { label: '月推薦榜', value: 'monthvote' }, + { label: '周推薦榜', value: 'weekvote' }, + { label: '月鮮花榜', value: 'monthflower' }, + { label: '周鮮花榜', value: 'weekflower' }, + { label: '月鷄蛋榜', value: 'monthegg' }, + { label: '周鷄蛋榜', value: 'weekegg' }, + { label: '最近更新', value: 'lastupdate' }, + { label: '最新入庫', value: 'postdate' }, + { label: '收藏榜', value: 'goodnum' }, + { label: '新書榜', value: 'newhot' }, + ], + type: FilterTypes.Picker, + }, + } satisfies Filters; +} + +export default new Linovelib_tw();