// ==UserScript== // @name 轻小说文库+ // @namespace https://greasyfork.org/users/667968-pyudng // @version 2.alpha.3.4 // @description 轻小说文库全方位体验改善,涵盖阅读、下载、书架、推荐、书评、账号、页面个性化等各种方面,你能想到的这里都有。没有?欢迎提出你的点子。 // @author PY-DNG // @license GPL-3.0-or-later // @homepageURL https://greasyfork.org/scripts/539514 // @supportURL https://greasyfork.org/scripts/539514/feedback // @match http*://*.wenku8.com/* // @match http*://*.wenku8.net/* // @match http*://*.wenku8.cc/* // @require data:application/javascript,window.setImmediate%20%3D%20window.setImmediate%20%7C%7C%20((f%2C%20...args)%20%3D%3E%20window.setTimeout(()%20%3D%3E%20f(args)%2C%200))%3B // @require https://update.greasyfork.icu/scripts/456034/1606773/Basic%20Functions%20%28For%20userscripts%29.js // @require https://update.greasyfork.icu/scripts/471280/1247074/URL%20Encoder.js // @require https://fastly.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js // @require https://fastly.jsdelivr.net/npm/ejs@3.1.9/ejs.min.js // @require https://fastly.jsdelivr.net/npm/jepub@2.1.4/dist/jepub.min.js // @require https://fastly.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js // @resource vue-js https://unpkg.com/vue@3.5.13/dist/vue.global.prod.js // @resource quasar-icon https://fonts.font.im/css?family=Roboto:100,300,400,500,700,900|Material+Icons // @resource quasar-css https://fastly.jsdelivr.net/npm/quasar@2.15.1/dist/quasar.prod.css // @resource quasar-js https://fastly.jsdelivr.net/npm/quasar@2.15.1/dist/quasar.umd.prod.js // @resource vue-js-bak https://fastly.jsdelivr.net/npm/vue@3.5.13/dist/vue.global.min.js // @resource quasar-icon-bak https://google-fonts.mirrors.sjtug.sjtu.edu.cn/css?family=Roboto:100,300,400,500,700,900|Material+Icons // @resource quasar-css-bak https://unpkg.com/quasar@2.15.1/dist/quasar.prod.css // @resource quasar-js-bak https://unpkg.com/quasar@2.15.1/dist/quasar.umd.prod.js // @connect wenku8.com // @connect wenku8.net // @connect wenku8.cc // @connect 777743.xyz // @icon https://www.wenku8.cc/favicon.ico // @grant GM_getResourceText // @grant GM_registerMenuCommand // @grant GM_setValue // @grant GM_getValue // @grant GM_addValueChangeListener // @grant GM_removeValueChangeListener // @grant GM_log // @grant GM_addElement // @grant GM_xmlhttpRequest // @grant GM_setClipboard // @downloadURL none // ==/UserScript== const Settings = require('settings'); /* eslint-disable no-multi-spaces */ /* eslint-disable no-return-assign */ /* global LogLevel DoLog Err Assert $ $All $CrE $AEL $$CrE addStyle detectDom destroyEvent copyProp copyProps parseArgs escJsStr replaceText getUrlArgv dl_browser dl_GM AsyncManager queueTask FunctionLoader loadFuncs require isLoaded default_pool */ /* global $URL, Vue, Quasar, Sortable, confetti, JSZip, jEpub */ (function __MAIN__() { 'use strict'; const CONST = { // UI用文本常量 TextAllLang: { DEFAULT: 'zh-CN', 'zh-CN': { ExportDebugInfo: '导出调试信息', EnableScriptDebugging: '开启调试', DisableScriptDebugging: '关闭调试', Announcements: { Running: `${GM_info.script.name} v${GM_info.script.version} 正在运行` }, Unlocker: { FetchingContent: `[${GM_info.script.name}] 正在获取章节内容...`, ConstructingPage: `[${GM_info.script.name}] 正在构建页面...`, FetchingDownloadInfo: `[${GM_info.script.name}] 正在获取下载信息...`, }, SidePanel: { PanelShowHide: '显示/隐藏', GotoTop: '回到顶部', GotoBottom: '跳至底部', Refresh: '刷新页面' }, Settings: { SideButtonLabel: '设置', DialogTitle: '设置', NeedsReload: '修改后需要重新加载页面以生效', OtherPageNeedsReload: '修改后其他页面需要重新加载以生效', SelectImage: '选择图片', Tabs: { ModuleSettings: '模块设置', About: '关于', AboutTab: '关于脚本', FAQ: '常见问题', }, About: { Version: `版本: ${ GM_info.script.version }`, Author: `作者: ${ GM_info.script.author }`, Homepage: `主页: Greasyfork`, get TechnicalNote() { return `${ GM_info.script.name } 使用自行编写的模块加载系统驱动,由 ${ Object.keys(functions).length } 个模块共同组成;在UI方面,使用了Vue.jsQuasar 框架,并以 Material Symbols & Icons 作为图标库。`; }, FAQ: [{ Q: '为什么模块的设置时常消失?', A: '模块只会在需要它的功能的页面运行,而在其他页面上由于模块不会运行,其设置项也不会在这些不运行的页面上存在。比如,「书评增强」模块的设置只会在书评页面出现。', }], }, }, Darkmode: { Switch2Dark: '切换到深色模式', Switch2Light: '切换到浅色模式', FollowEnabledTip: '您已开启深色模式跟随系统,此时手动切换深色模式无作用', FollowEnabledTipCaption: '您可到设置中关闭深色模式跟随系统,即可手动切换深色模式', Settings: { Label: '深色模式', Enbaled: '启用深色模式', EnabledCaption: '此项亦可在右下角侧边栏按钮中快速切换', FollowSystem: '深色模式跟随系统', FollowSystemCaption: '此项启用后优先级高于上面的手动开关', SideButton: '侧边栏快捷切换按钮', SideButtonCaption: '用于手动控制深色模式开关', }, }, Review: { FloorManager: { UpdatingFloors: '正在更新楼层...', FloorUpdated: '楼层已更新', FloorUpdatedCaption: '发现 {Updated} 条新内容' }, Cite: { Cite: '引用' }, UBBEditor: { InsertImage: { InputUrl: '请输入图片链接:', Title: '插入图片', Ok: '完成', Cancel: '取消', UrlFormatTip: '图片链接应该以 "http://" 或 "https://" 开头,以".jpg" 或 ".png" 等图片文件扩展名结尾', }, InsertUrl: { InputUrl: '请输入链接:', Title: '插入链接', Ok: '完成', Cancel: '取消', UrlFormatTip: '链接应该以 "http://" 或 "https://" 开头', }, }, ReplyInPage: { NoEmptyContent: '已成功发送空气', NoEmptyContentCaption: '不可发送空白内容', SendingReply: '正在发送评论...', ReplySent: '已提交评论', SentStatusDetails: '查看详情', DetailsOk: '已阅', }, Settings: { Label: '书评吐嘈增强', NoContent: '引用时仅引用楼号', NoContentCaption: '[url=yidxxxxx]#1[/url]', Pangu: '引用隔离', PanguCaption: '保持引用部分和周围文字之间有且仅有一个空格', Select: '引用后选中', SelectCaption: '引用后,选中插入到输入框的、引用的文字部分', FloorJump: '页面内跳转(楼层)', FloorJumpCaption: '点击书评中到某一楼层的链接时,若链接楼层就在本页内,直接跳转至楼层,不再重新加载页面', PageJump: '页面内跳转(页码)', PageJumpCaption: '点击右下角切换评论页数时,直接在页面内更新到该页,不再重新加载页面', ReplyInPage: '页面内发送评论', ReplyInPageCaption: '发送评论后页面内更新,不再刷新页面', EditInPage: '页面内编辑评论', EditInPageCaption: '页面内弹窗编辑,不再打开新标签页', AutoRefresh: '楼层自动刷新', get AutoRefreshCaption() { return `每隔${ CONST.Internal.ReviewAutoRefreshInterval / 1000 }s自动刷新页面内评论,并高亮显示新的楼层和被修改过的楼层`; }, RefreshToLast: '刷新到最后一页', RefreshToLastCaption: '楼层自动刷新时,总是刷新到书评的最后一页,而不是当前所在页码', }, }, UserRemark: { RemarkUser: '用户备注', RemarkDisplay: '用户备注: {Remark}', RemarkNotSet: '未设置用户备注', Prompt: { Title: '为用户设置备注', Message: '您要为用户 {Name} 设置的备注为:', Ok: '保存', Cancel: '取消', Saved: '已保存' }, Settings: { Label: '用户备注', Enabled: '启用用户备注功能', EnabledCaption: '若不启用,则不会在页面中显示相关UI', } }, MetaCopy: { CopyButton: '[复制]', Copied: '已复制', }, Bookcase: { Collector: { FetchingBookcases: '正在调阅书架...', ArrangingBookcases: '正在整理书架...', UpdatingBookcase: '正在更新书架...', SubmitingChange: '正在提交更改...', RefreshBookcase: '刷新书架内容', Refreshed: '书架已刷新', Removed: '已移出书架', ActionFinished: '已{ActionName}', NoBooksSelected: '请先选择要操作的书目!', Dialog: { ConfirmRemove: { Message: '确实要将 {Name} 移出书架么?', Title: '移出书籍', ok: '是的', cancel: '还是算了' }, }, }, Naming: { DefaultName: '第{ClassID}组书架', Rename: '重命名书架', MoveTo: '移到{Name}', Dialog: { PromptNewName: { Message: '请给 {OldName} 取个新名字吧:', Title: '重命名书架', Ok: '保存', Cancel: '取消', } }, }, AddpageJump: { GotoBookcase: '前往书架', }, }, ReadLater: { Add: '添加到稍后再读', Added: '添加成功', AddSuccess: '稍后再读 {Name}', AddDuplicate: '{Name} 已经在稍后再读中了,要不要现在就读一读呢?', Title: '稍后再读(拖动可排序)', EmptyListPlaceholder: '添加到稍后再读的书籍会显示在这里', }, BlockFolding: { Fold: '折叠', UnFold: '展开', }, Downloader: { SideButton: '下载器', Title: '文库下载器', Notes: `

本书轻小说文库链接:{URL}
Epub电子书由${GM_info.script.name}合成。

本资源仅供试读,如喜爱本书,请购买正版。

`, Options: { Format: { Title: '格式', txt: 'TXT 文档', epub: 'Epub 电子书', image: '仅插图', }, Encoding: { Title: '编码', Caption: '仅对txt文档生效', gbk: 'GBK', utf8: 'UTF-8' }, Filename: '文件名', }, UI: { DownloadButton: '下载', Author: '作者: ', LastUpdate: '最后更新: ', Tags: '作品Tags: ', BookStatus: '状态: ', Intro: '内容简介: ', ContentSelectorTitle: '请选择下载的章节: ', NoContentSelected: '已勾选的下载章节为空', Progress: { Global: '当前步骤 ({CurStep}/{Total}): {Name}', Sub: '当前进度: {CurStep}/{Total}', Ready: '下载器准备就绪', Loading: '正在加载书籍信息...', } }, Steps: { txt: { NovelContent: '下载章节内容', EncodeText: '编码文本', GenerateZIP: '合成ZIP文件', }, image: { NovelContent: '下载章节内容', DownloadImage: '下载图片', GenerateZIP: '合成ZIP文件', }, epub: { NovelContent: '加载章节内容和图片', GenerateEpub: '合成Epub文件', }, }, }, Autovote: { Add: '添加到自动推书', Added: '添加成功', AddSuccess: '将 {Name} 添加到了每日自动推书中', AddDuplicate: '其实 {Name} 已经在自动推书列表中了', Configure: '自动推书配置', VoteStart: '开始自动推书...', VoteEnd: '自动推书完成', VoteDetail: '详情', UI: { Title: '自动推书配置', Votes: '每日推荐票数', TimeAdded: '添加时间: ', VotedCount: '累计自动推书: ', ConfirmRemove: { Title: '从自动推书中移除书籍', Message: '确实要将 {Name} 从自动推书中移除吗?移除后,以前的推书记录也将一同被删除。', Ok: '确定', Cancel: '还是算了', }, }, Settings: { Title: '自动推书', Configuration: '自动推书配置', Configure: '编辑', Enabled: '启用自动推书', EnabledCaption: '关闭后将不再每日自动推书、不显示相关UI,但推书配置和记录仍将保留', } }, ReviewCollection: { CollectionTitle: '书评收藏', Add: '收藏书评', Remove: '取消收藏书评', Added: '已添加到书评收藏', Removed: '已取消收藏此书评', Settings: { Title: '书评收藏', Enabled: '启用书评收藏', EnabledCaption: '关闭后,将不再显示相关UI,但收藏的书评仍将保留', ListPosition: '首页收藏列表放置位置', ListPositionCaption: '在哪里显示收藏的书评', ListPositionLeft: '左侧', ListPositionRight: '右侧', OpenLastPage: '打开书评最后一页', OpenLastPageCaption: '从首页的书评收藏列表中打开书评时,直接跳转到书评最后一页', }, }, Background: { Settings: { Title: '自定义背景', Enabled: '启用自定义背景', EnabledCaption: '启用后,将改变页面背景,覆盖文库自带白色背景和深色模式的黑色背景', Type: '背景类型', Types: [{ label: '本地图片', value: 'local' }, { label: '网络图片', value: 'url' }, { label: '纯色', value: 'color' }], ImageUrl: '网络图片链接', Image: '本地图片', MaskOpacity: '图片遮罩层不透明度', Color: '颜色', }, }, } }, /** * @returns {typeof CONST.TextAllLang['zh-CN']>} */ get Text() { const i18n = Object.keys(CONST.TextAllLang).includes(navigator.language) ? navigator.language : CONST.TextAllLang.DEFAULT; return CONST.TextAllLang[i18n]; }, // 文库内部所用常量 Wenku8: { /** @typedef {typeof CONST.Wenku8.LanguageCode} LanguageCode */ LanguageCode: { Simplified: 0, Traditional: 1 } }, // 脚本内部配置 Internal: { // 用于各类解锁时,取得DOM等模板所用的未锁定书籍的aid UnlockTemplateAID: 1, // 最长存储日志页面数量 DefaultLogMaxPage: 10, // 最长存储日志条数 DefaultLogMaxLength: 30, // 最长存储错误数量 DefaultErrorMaxLength: 10, // 板块折叠:消失的板块所对应的折叠记录在连续观察到几次从文档中消失后清除 RemoveBlockFoldingCount: 10, // 自动推书:其他标签页存活检测 最长更新时间间隔 AutovoteActiveTimeout: 10 * 1000, // 书评楼层自动刷新间隔 ReviewAutoRefreshInterval: 20 * 1000, // 默认书评收藏 BuiltinReviewCollection: [{ rid: 298520, name: '[轻小说文库+] 脚本反馈站' }, { rid: 228884, name: '文库导航姬', }, { rid: 282295, name: '文库导航 / 中转站', }], }, }; const functions = { utils: { /** @typedef {Awaited>} utils */ func() { /** @type {typeof window} */ const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; // 当日志模块加载完毕时,记录日志 require('logger', true).then(() => { /** @type {logger} */ const logger = require('logger'); logger.log( logger.LogLevel.Info, `${GM_info.script.name} v${GM_info.script.version} starting` ); }); // 当基础框架功能集加载完毕时,记录日志 Promise.all( ['utils', 'debugging', 'logger', 'dependencies'].map(id => require(id, true)) ).then(() => { /** @type {logger} */ const logger = require('logger'); logger.log( logger.LogLevel.Message, `${GM_info.script.name} v${GM_info.script.version} running` ); }); // 当全部可加载功能加载完毕时,记录日志 $AEL(default_pool, 'all_load', e => { /** @type {logger} */ const logger = require('logger'); logger.log( logger.LogLevel.Info, `[${GM_info.script.name}] all functions loaded` ); }); /** * 获取当前页面的语言:繁体中文/简体中文 * @returns {number} 文库语言代码,参考 {@link LanguageCode} */ function getLanguage() { if ('currentEncoding' in win) { return { 1: CONST.Wenku8.LanguageCode.Traditional, 2: CONST.Wenku8.LanguageCode.Simplified, }[win.currentEncoding]; } else { return { 'GBK': CONST.Wenku8.LanguageCode.Simplified, 'Big5': CONST.Wenku8.LanguageCode.Traditional, }[document.characterSet]; } } /** * 向输入框的当前光标位置中插入文本 * @param {HTMLTextAreaElement | HTMLInputElement} elm - 输入框元素 * @param {string} text - 待插入的文本 * @param {string} [pangu=false] - 是否保证插入部分和周围文本之间至少有一个空格 * @param {string} [select=false] - 是否选中插入部分内容 */ function insertText(elm, text, pangu=false, select=false) { const orig_start = elm.selectionStart; let before_selection = elm.value.slice(0, elm.selectionStart); let after_selection = elm.value.slice(elm.selectionEnd); if (pangu) { // 当前面有内容时,将前面内容的结尾空格替换为1个 if (before_selection && !before_selection.endsWith('\n')) { before_selection = before_selection.replace(/ +$/g, ''); text = ' ' + text; } // 无论后面是否有内容,均将后面内容的开头空格替换为1个 after_selection = after_selection.replace(/^ +/g, ''); text = text + ' '; } elm.value = before_selection + text + after_selection; const text_end = orig_start + text.length; if (select) { elm.setSelectionRange(orig_start, text_end, 'forward'); } else { elm.setSelectionRange(text_end, text_end, 'none'); } } /** * 新建一个FuncPool加载oFuncs,oFuncs以对象格式书写(而非标准的数组格式) * 返回 { promise, pool }, promise将会在加载完毕时resolve,pool为加载时创建的新FuncPool * @param {Object} pool_funcs * @param {Record<'GM_getValue' | 'GM_setValue' | 'GM_listValues' | 'GM_deleteValue', function>} [GM_funcs={}] * @returns {{ promise: Promise, pool: InstanceType }} */ function loadFuncInNewPool(pool_funcs, GM_funcs={}) { /** * @param {InstanceType} pool * @param {Object} oFuncs */ async function loadWithErrorHandling(pool, oFuncs) { /** @type {debugging} */ const debugging = await require('debugging', true); debugging.catchPoolErrors(pool); // 确保oFuncs一定在下个事件循环及以后加载 // 防止pool还没return就同步加载完成了 // 导致外部调用方运行时无法获取pool报错 return new Promise(resolve => setTimeout( () => pool.load(oFuncs).then(() => resolve()) ) ); } const pool = new FunctionLoader.FuncPool({ GM_funcs }); const oFuncs = Object.entries(pool_funcs).reduce((arr, [id, oFunc]) => { oFunc.id = id; arr.push(oFunc); return arr; }, []); const promise = loadWithErrorHandling(pool, oFuncs); return { promise, pool }; } /** * 创建存储的默认值层,定义默认值后,读取对应键时若无已设置值则返回默认值 * 返回带默认值的 GM_getValue 函数 * @param {Record} default_values - 存储默认值对象 * @param {typeof GM_getValue} orig_get - GM_getValue函数 */ function defaultedGet(default_values, orig_get) { const Empty = Symbol('defaultedGet: no value written'); default_values = window.structuredClone(default_values); return GM_getValue; /** * 带默认值层的GM_getValue读存储函数,会在存储中未写入值时 * @param {*} key - 需读取的存储的键 * @param {*} defaultValue - 本次读取时使用的默认值,本次读取中优先级高于之前定义的默认值对象 */ function GM_getValue(key, defaultValue = Empty) { // 之前设置的默认值对象中,此键的默认值 const global_default = default_values.hasOwnProperty(key) ? default_values[key] : null; // 本次调用中,显式设置的默认值 const current_default = defaultValue; // 最终使用的默认值 const default_val = current_default !== Empty ? current_default : global_default; // 读取值并返回 const val = orig_get(key, default_val); return val; } } /** * 从诸如"普通会员","禁言會員"这样的文字中确定用户组类型 * @param {string} text * @returns { 'user' | 'admin' | 'banned' | 'limited' } */ function getUserType(text) { return ({ // 简体,繁体(推荐),繁体(备用) '普通会员': 'user', '普通會員': 'user', '喱通会员': 'user', '系统管理员': 'admin', '系統管理員': 'admin', '系统嗷理员': 'admin', '禁言会员': 'banned', '禁言會員': 'banned', '禁言會員': 'banned', '受限会员': 'limited', '受限會員': 'limited', '受限會員': 'limited', }) [text]; } /** * 从诸如"新手上路","高級會員"这样的文字中确定用户等级 * @param {string} text * @returns { 'newbie' | 'normal' | 'intermediate' | 'advanced' | 'golden' | 'elder' } */ function getUserLevel(text) { return ({ // 简体,繁体(推荐),繁体(备用) '新手上路': 'newbie', '新手上路': 'newbie', '新手上路': 'newbie', '普通会员': 'normal', '普通會員': 'normal', '普通會員': 'normal', '中级会员': 'intermediate', '中級會員': 'intermediate', '中級會員': 'intermediate', '高级会员': 'advanced', '高級會員': 'advanced', '坨级会员': 'advanced', '金牌会员': 'golden', '金牌會員': 'golden', '金牌會員': 'golden', '本站元老': 'elder', '本站元老': 'elder', '本站元老': 'elder', }) [text]; } /** * 将给定的方法包装为排队执行的版本,返回的新方法将在队列中执行,以限制最大并行执行数并添加执行间隔 * @template {function} F * @param {F} func * @param {Object} [options] * @param {Object} [options.queue_id] 并行队列id,相同的id将在同一队列内运行;省略时生成随机id * @param {Object} [options.max=5] 最大并行执行数 * @param {Object} [options.sleep=0] 每两次执行间的等待间隔时长 * @returns {F} */ function toQueued(func, { queue_id=null, max = 5, sleep = 0 } = {}) { queue_id === null && (queue_id = 'toQueued-' + randstr()); queueTask[queue_id] = { max, sleep }; return function queued(...args) { return queueTask(() => func(...args), queue_id); }; } /** * 以当前网页的编码将form元素内容或者FormData对象序列化为post表单字符串 * @param {HTMLFormElement | FormData} form * @returns {string} */ function serializeFormData(form) { /** @type {FormData} */ const formdata = Object.prototype.toString.call(form) === '[object FormData]' ? form : new FormData(form); return [...formdata.entries()].map(([key, val]) => `${ encodeURIComponent(key) }=${ encodeURIComponent(val) }`).join('&'); /** * 和标准同名方法一致,但是根据当前文档的编码进行 * @type {typeof window.encodeURIComponent} */ function encodeURIComponent(text) { return Array.from(text).map(char => /[A-Za-z0-9\-_\.!~\*'\(\)]/.test(char) ? char : $URL.encode(char) ).join(''); } } /** * 在给定字符串头部填0使字符串达到给定长度 * @param {string} text * @param {number} len * @returns {String} */ function zfill(text, len) { return '0'.repeat(Math.max(0, len - text.length)) + text; } /** * Encode text into html text format * @param {string} text * @returns {string} */ function htmlEncode(text) { const span = $CrE('div'); span.innerText = text; return span.innerHTML; } /** * 随机字符串 * @param {number} length - 随机字符串长度 * @param {boolean} cases - 是否包含大写字母 * @param {string[]} aviod - 需要排除的字符串,在这里的字符串不会作为随机结果返回;通常用于防止随机出重复字符串 * @returns {string} */ function randstr(length=16, cases=true, aviod=[]) { const all = 'abcdefghijklmnopqrstuvwxyz0123456789' + (cases ? 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' : ''); while (true) { let str = ''; for (let i = 0; i < length; i++) { str += all.charAt(randint(0, all.length-1)); } if (!aviod.includes(str)) {return str;}; } } /** * 随机整数 * @param {number} min - 最小值(包含) * @param {number} max - 最大值(包含) * @returns {number} */ function randint(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } /** * 调用GM_xmlhttpRequest并按照当前页面字符编码解析返回的文本 * 传入的detail对象中的onload将会被替换为文本字符解码器,因此自定义的onload将不会被执行 * @param {*} detail * @returns {Promise} */ function requestText(detail) { const { promise, resolve } = Promise.withResolvers(); detail.responseType = 'arraybuffer'; detail.onload = response => { const buffer = (response.response); const decoder = new TextDecoder(document.characterSet); const text = decoder.decode(buffer); resolve(text); }; GM_xmlhttpRequest(detail); return promise; } /** * 调用GM_xmlhttpRequest并按照当前页面字符编码解析返回的文本为文档 * 传入的detail对象中的onload将会被替换为文本字符解码器,因此自定义的onload将不会被执行 * @param {*} detail * @returns {Promise} */ async function requestDocument(detail) { const source = await requestText(detail); const doc = new DOMParser().parseFromString(source, 'text/html'); return doc; } const requestBlob = toQueued(_requestBlob, { max: 5, sleep: 0, queue_id: 'blob_request' }); /** * 获取指定url的文件为blob * @param {string} url * @returns {Promise} */ function _requestBlob(url) { const { promise, resolve } = Promise.withResolvers(); GM_xmlhttpRequest({ method: 'GET', url, responseType: 'blob', onload(response) { resolve(response.response); } }); return promise; } /** * 获取OPFS中指定模块的目录 * 注意:这里并不使用OPFS的全部命名空间,而是将全部脚本所用存储存放到OPFS:WenkuPlus目录中,为日后网站官方开发预留主要命名空间 * @param {string} id - 指定模块oFunc.id */ async function getModuleDir(id) { const root = await navigator.storage.getDirectory(); const script_root = await root.getDirectoryHandle('WenkuPlus', { create: true }); const dir = await script_root.getDirectoryHandle(id, { create: true }); return dir; } /** * @param {HTMLElement} elm * @param {string} content */ function setTip(elm, content) { elm.setAttribute('tiptitle', content); $AEL(elm, 'mouseover', e => win.tipshow(elm.getAttribute('tiptitle'))); $AEL(elm, 'mouseout', e => win.tiphide('tiptitle')); } /** * Async task progress manager \ * when awaiting async tasks, replace `await task` with `await manager.progress(task)` \ * suppoerts sub-tasks, just `manager.sub(sub_steps, sub_callback)` */ class ProgressManager extends EventTarget { /** @type {*} */ info; /** @type {number} */ steps; /** @type {number} */ finished; /** @type {'none' | 'sub' | 'self'} */ error; /** @type {ProgressManager[]} */ #children; /** @type {ProgressManager} */ #parent; /** * This callback is called each time a promise resolves * @callback progressCallback * @param {number} resolved_count * @param {number} total_count * @param {ProgressManager} manager */ /** * @param {number} [steps=0] - total steps count of the task * @param {progressCallback} [callback] - callback each time progress updates * @param {*} [info] - attach any data about this manager if need */ constructor(steps=0, info=undefined) { super(); this.steps = steps; this.info = info; this.finished = 0; this.error = 'none'; this.#children = []; this.#broadcast('progress'); } add() { this.steps++; } /** * @template {Promise | null} task * @param {task} [promise] - task to await, null is acceptable if no task to await * @param {number} [finished] - set this.finished to this value, adds 1 to this.finished if omitted * @param {boolean} [accept_reject=true] - whether to treat rejected promise as resolved; if true, callback will get error object in arguments; if not, progress function itself rejects * @returns {Promise>} */ async progress(promise, finished, accept_reject = true) { let val; try { val = await Promise.resolve(promise); } catch(err) { this.newError('self', false); if (!accept_reject) { throw err; } } try { this.finished = (typeof finished === 'number' && finished >= 0) ? finished : this.finished + 1; this.#broadcast('progress'); //this.finished === this.steps && this.#parent && this.#parent.progress(); } finally { return val; } } /** * New error occured in manager's scope, update error status * @param {'none' | 'sub' | 'self'} [error='self'] * @param {boolean} [callCallback=true] */ newError(error = 'self', callCallback = true) { const error_level = ['none', 'sub', 'self']; if (error_level.indexOf(error) <= error_level.indexOf(this.error)) { return; } this.error = error; this.#parent && this.#parent.newError('sub'); callCallback && this.#broadcast('error'); } /** * Creates a new ProgressManager as a sub-progress of this * @param {number} [steps=0] - total steps count of the task * @param {*} [info] - attach any data about the sub-manager if need */ sub(steps, info) { const manager = new ProgressManager(steps ?? 0, info); manager.#parent = this; this.#children.push(manager); this.#broadcast('sub'); return manager; } /** * reset this to an empty manager */ reset() { this.steps = 0; this.finished = 0; this.#parent = null; this.#children = []; this.#broadcast('reset'); } #broadcast(evt_name) { //this.callback(this.finished, this.steps, this); this.dispatchEvent(new CustomEvent(evt_name, { detail: { type: evt_name, manager: this } })); } get children() { return [...this.#children]; } get parent() { return this.#parent; } } return { // 窗口 window: win, // 文库相关 getLanguage, getUserType, getUserLevel, // 文库页面功能, setTip, // 功能相关 insertText, loadFuncInNewPool, defaultedGet, requestText, requestDocument, requestBlob, getModuleDir, // 管理器 ProgressManager, // 算法相关 toQueued, serializeFormData, zfill, htmlEncode, randstr, randint, }; } }, debugging: { desc: 'script error handler and debugging tool', dependencies: 'logger', params: ['GM_setValue', 'GM_getValue'], /** @typedef {Awaited>} debugging */ async func(GM_setValue, GM_getValue) { /** * @typedef {Object} debugging_storage * @property {ErrorObject[]} errors - 错误存档 * @property {number} max_save - 最大错误存档长度 * @property {number} script_debug - 脚本是否处于调试状态 */ /** @type {logger} */ const logger = require('logger'); // Automatically record default funcpool load errors catchPoolErrors(default_pool); // 调试模式接口 GM_getValue('script_debug', false) && enableScriptDebugging(); // Menu commands // Delay 1s to put menu item into last place in menus list setTimeout(() => { GM_registerMenuCommand(CONST.Text.ExportDebugInfo, exportDebugInfo); toggleScriptDebug('script_debug', false); /** * * @param {boolean} toggle - 是否实际改变脚本调试状态,如果为false,则仅更新/创建菜单项 * @param {string | number} [menu_id] - 需要更新的现有菜单项的id,不提供则新建菜单项 * @returns */ function toggleScriptDebug(menu_id, toggle=true) { const script_debug = toggle === GM_getValue('script_debug', false); let label; if (script_debug) { // 已处于调试模式,关闭调试模式,提供开启按钮 toggle && disableScriptDebugging(); label = CONST.Text.EnableScriptDebugging; } else { // 未处于调试模式,开启调试模式,提供关闭按钮 toggle && enableScriptDebugging(); label = CONST.Text.DisableScriptDebugging; } const options = {}; GM_registerMenuCommand(label, () => toggleScriptDebug(menu_id, true), { id: menu_id }); } }, 1000); /** * @typedef {Object} ErrorDetail * @property {string} [key] - use key to avoid saving same error multiple times * @property {string} type * @property {Error} error * @property {*} info */ /** * @typedef {Object} ErrorObject * @property {string} [key] * @property {string} type * @property {*} info * @property {string} message * @property {string | undefined} stack * @property {string} url * @property {boolean} iframe * @property {number} timestamp */ /** * wrap error details into error object * @param {ErrorDetail} detail * @returns {ErrorObject} */ function wrapErrorData({type, error, info, key}) { const data = { type, info, message: error.message, stack: error.stack, url: location.href, iframe: window.top !== window, timestamp: Date.now() }; key && (data.key = key); return data; } /** * Save an error into storage * @param {ErrorDetail} detail * @returns {ErrorObject} */ function saveError({type, error, info, key}) { const data = wrapErrorData({type, error, info, key}); const errors = GM_getValue('errors', []); if (key && errors.some(error => error.key === key)) { return; } errors.push(data); const max_save = GM_getValue('max_save', CONST.Internal.DefaultErrorMaxLength); while (errors.length > max_save) { errors.shift(); } GM_setValue('errors', errors); return data; } /** @typedef {InstanceType} FuncPool */ /** * Automatically catch and save all errors from FuncPool loaded oFuncs * @param {FuncPool} pool */ function catchPoolErrors(pool) { pool.catch_errors = true; pool.addEventListener('error', e => { const { error, oFunc } = e.detail; dealLoadError(error, oFunc); }); pool.errors.forEach(({error, oFunc}) => dealLoadError(error, oFunc)); function dealLoadError(error, oFunc) { saveError({ type: 'oFunc', error, info: { id: oFunc.id }, //key: `oFunc-${oFunc.id}` }); if (GM_getValue('script_debug', false)) { throw error; } else { logger.error(logger.LogLevel.Error, error); } }; } /** * @callback ErrorHandler * @param {Error} err - the error object * @param {function} func - function tried to run * @param {any} thisArg - thisArg passed to the function * @param {any[]} args - thisArg passed to the function * @returns {boolean} whether to save this error */ /** * Call given function with error handling * @template {function} F * @param {F} func * @param {any} [thisArg] * @param {any[]} [args] * @param {ErrorHandler} [handler] - callback when error occurs * @returns {ReturnType} */ function callWithErrorHandling(func, thisArg=null, args=[], handler=null) { try { return func.apply(thisArg, args); } catch (err) { const save = typeof handler === 'function' ? handler(err, func, thisArg, args) : true; save && saveError({ type: 'func-error', error: err, info: { func/*, thisArg, args*/ } // thisArg and args may contain circular structure }); if (GM_getValue('script_debug', false)) { throw err; } else { logger.error(logger.LogLevel.Error, err); } } } /** * Export an error to user as a json file * returns error object * @param {ErrorDetail} detail * @returns {ErrorObject} */ function exportError({type, error, info, key}) { const data = wrapErrorData({type, error, info, key}); download_object(data, `${GM_info.script.name} Error.json`); return data; } /** * Export all saved errors to user as a json file */ function exportAllErrors() { const errors = GM_getValue('errors', []); download_object(errors, `${GM_info.script.name} All Errors.json`); } function exportDebugInfo() { const errors = GM_getValue('errors', []); const logs = logger.pages; const debug_info = { errors, logs, ua: navigator.userAgent, version: GM_info.script.version, manager: GM_info.scriptHandler, manager_version: GM_info.version, timestamp: Date.now(), }; download_object(debug_info, `${GM_info.script.name} Debug Info.json`); } /** * download any jsonable data as file * @param {*} data - any jsonable data * @param {string} filename * @param {string} mimetype */ function download_object(data, filename, mimetype='application/json') { const json = JSON.stringify(data); const url = URL.createObjectURL(new Blob([json], { type: mimetype })); dl_browser(url, filename); setTimeout(() => URL.revokeObjectURL(url)); } function enableScriptDebugging() { // Do not depend on utils (or any other dependencies) while debugging GM_setValue('script_debug', true); const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; win.w8p = { // 脚本实现的接口 require, default_pool, // 脚本@require的接口 $URL, confetti, }; logger.log(logger.LogLevel.Message, `[${GM_info.script.name}]\nScript debugging enabled.\nDebugging interface injected as %cwindow.w8p%c.`, 'color: #6666CC;', ''); } function disableScriptDebugging() { GM_setValue('script_debug', false); const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; delete win.w8p; logger.log(logger.LogLevel.Message, `[${GM_info.script.name}] Script debugging disabled.`); } return { wrapErrorData, saveError, catchPoolErrors, callWithErrorHandling, exportError, exportAllErrors, exportDebugInfo, enableScriptDebugging, /** @type {ErrorObject[]} */ get errors() { return GM_getValue('errors', []); }, /** @type {number} */ get max_save() { return GM_getValue('max_save', 10); }, set max_save(val) { GM_setValue('max_save', val); }, /** @type {boolean} */ get script_debug() { return GM_getValue('script_debug', false); }, set script_debug(val) { GM_setValue('script_debug', val); } }; } }, logger: { dependencies: 'utils', params: ['GM_setValue', 'GM_getValue'], /** @typedef {Awaited>} logger */ func(GM_setValue, GM_getValue) { /** * @typedef {Object} logger_storage * @property {LogPage[]} pages * @property {number} [loglevel] - 日志输出级别 * @property {number} [max_pages] - 最多存储页面数量 * @property {number} [max_logs] - 每个页面最多存储日志条数 */ /** @type {utils} */ const utils = require('utils'); const csl = Object.assign({}, console); const LogLevel = { // 仅作调试用途 Debug: 0, // 详细运行日志 Info: 1, // 运行日志中可能需要关注的部分 Warn: 2, // 运行过程中的主要(简略)日志内容 Message: 2, // 报错日志 Error: 3, }; /** * @typedef {Object} LogData * @property {number} level - 日志级别,仅用于筛选是否在控制台输出,不会改变输出内容和格式 * @property {keyof typeof csl} funcname - 使用的log函数名,如'log','error'等 * @property {any} content * @property {number} timestamp * @property {string} url * @property {boolean} iframe */ /** * 代表一个页面上的全部日志 * @typedef {Object} LogPage * @property {number} id - 页面id,用 performance.timeOrigin 表示 * @property {LogData[]} logs * @property {string} url * @property {number | null} parent - 若页面在iframe中,为父页面的id;若不在,则为null */ /** * wrap content into standard log data format * @param {number} level - 日志级别,仅用于筛选是否在控制台输出,不会改变输出内容和格式 * @param {keyof typeof csl} funcname - 使用的log函数名,如'log','error'等 * @param {*} content * @returns {LogData} */ function wrapLog(level, funcname, content) { return { level, funcname, content, timestamp: Date.now(), url: location.href, iframe: utils.window.top !== utils.window }; } /** * 获取当前页面的日志对象id * @returns {number} */ function getCurPageID() { return utils.window.performance.timeOrigin; } /** * 获取当前页面的日志对象 * @returns {LogPage} */ function getCurPage() { const id = getCurPageID(); return GM_getValue('pages').find(page => page.id === id); } /** * 获取当前日志输出级别 * @returns {number} */ function getLoglevel() { return GM_getValue('loglevel', LogLevel.Message); } /** * 设置日志输出级别 * @param {number} level */ function setLoglevel(level) { GM_setValue('loglevel', level); } /** @typedef {number | keyof typeof LogLevel} LogLevelArg */ /** * 输出、记录日志,和console.log基本相同 * 新增参数:第一个参数level日志级别,第二个参数使用的log函数 * @param {LogLevelArg} level - 日志级别,仅用于筛选是否在控制台输出,不会改变输出内容和格式;可以是数字或者其名称(不区分大小写);参考 {@link LogLevel} * @param {keyof typeof csl} funcname - 使用的log函数名,如'log','error'等 * @param {Parameters} content - 日志内容 * @returns {LogData} 当前页面的日志对象 */ function _log(level, funcname, ...content) { if (typeof level === 'string') { const standard_levelname = level.at(0).toUpperCase() + level.slice(1).toLowerCase(); if (LogLevel.hasOwnProperty(standard_levelname)) { level = LogLevel[standard_levelname] ?? level; } else { Err(`日志级别应为数字或LogLevel中声明的字符串关键字,而不是 ${ JSON.stringify(level) }`, TypeError); } } // 根据level输出到控制台 level >= getLoglevel() && csl[funcname](...content); // 获取页面日志对象 const pages = GM_getValue('pages', []); /** @type {LogPage} */ const page = pages.find(page => page.id === getCurPageID()) ?? { id: performance.timeOrigin, logs: [], parent: utils.window.parent !== utils.window ? utils.window.parent.performance.timeOrigin : null, url: location.href, }; const logs = page.logs; // 写入页面日志对象,并删除超限旧数据 logs.push(wrapLog(level, funcname, content)); logs.splice(0, logs.length - GM_getValue('max_logs', CONST.Internal.DefaultLogMaxLength)); !pages.includes(page) && pages.push(page); pages.splice(0, pages.length - GM_getValue('max_pages', CONST.Internal.DefaultLogMaxPage)); // 保存 GM_setValue('pages', pages); return logs; } /** * @param {LogLevelArg} level * @param {...any} content */ function log(level, ...content) { _log(level, 'log', ...content); } /** * @param {LogLevelArg} level * @param {...any} content */ function error(level, ...content) { _log(level, 'error', ...content); } /** * @param {LogLevelArg} level * @param {...any} content */ function warn(level, ...content) { _log(level, 'warn', ...content); } return { // 日志输出等级 get loglevel() { return getLoglevel(); }, set loglevel(val) { setLoglevel(val); }, // 只读日志对象 get pages() { return GM_getValue('pages'); }, get logs() { return getCurPage(); }, // 日志等级表 LogLevel, // 记录日志功能函数 log, error, warn, }; } }, dependencies: { desc: 'load dependencies like vue into the page', detectDom: ['head', 'body'], async func() { const StandbySuffix = '-bak'; const deps = [{ name: 'vue-js', type: 'script', }, { name: 'quasar-icon', type: 'style' }, { name: 'quasar-css', type: 'style' }, { name: 'quasar-js', type: 'script' }]; await Promise.all(deps.map(dep => { return new Promise((resolve, reject) => { const resource_text = GM_getResourceText(dep.name) || GM_getResourceText(dep.name + StandbySuffix); switch (dep.type) { case 'script': { // Once load, dispatch load event on messager const evt_name = `load:${dep.name};${Date.now()}`; const rand = Math.random().toString(); const messager = new EventTarget(); const load_code = [ '\n;', `window[${escJsStr(rand)}].dispatchEvent(new Event(${escJsStr(evt_name)}));`, `delete window[${escJsStr(rand)}];\n` ].join('\n'); unsafeWindow[rand] = messager; $AEL(messager, evt_name, resolve); GM_addElement(document.head, 'script', { textContent: `/* ${dep.name} */\n` + resource_text + load_code, }); break; } case 'style': { GM_addElement(document.head, 'style', { textContent: `/* ${dep.name} */\n` + resource_text, }); resolve(); break; } } }); })); // 创建一个Vue app并调用Quasar以进行初始化,以使用Quasar插件(Quasar.Dialog, Quasar.Loading等等) const app = Vue.createApp({}); app.use(Quasar); // configurations Quasar.setCssVar('primary', '#6f9ff1'); //Quasar.setCssVar('secondary', '#12b5a5'); Quasar.setCssVar('negative', '#e63c4f'); require('darkmode', true).then( /** @param {darkmode} darkmode */ darkmode => setTimeout(() => Quasar.Dark.set(darkmode.actual_enabled)) ); addStyle(` /* 自动对应深色和浅色模式的背景颜色和文字颜色 */ .body--light .text-lightdark { color: black; } .body--light .bg-lightdark { background: #fff; } .body--dark .text-lightdark { color: #fff; } .body--dark .bg-lightdark { background: var(--q-dark); } .body--light .bg-active { background: #EDEDED; } .body--dark .bg-active { background: #2A2A2A; } `); Quasar.Notify.registerType('info', { color: 'lightdark', textColor: 'lightdark', icon: 'info', iconColor: 'primary', position: 'top-right', badgeColor: 'primary', badgeTextColor: 'lightdark', }); Quasar.Notify.registerType('success', { color: 'lightdark', textColor: 'lightdark', icon: 'done', iconColor: 'primary', position: 'top-right', badgeColor: 'primary', badgeTextColor: 'lightdark', }); Quasar.Notify.registerType('warning', { color: 'lightdark', textColor: 'warning', icon: 'info', iconColor: 'warning', position: 'top-right', badgeColor: 'warning', badgeTextColor: 'lightdark', }); Quasar.Notify.registerType('error', { color: 'lightdark', textColor: 'negative', icon: 'close', iconColor: 'negative', position: 'top-right', badgeColor: 'negative', badgeTextColor: 'lightdark', }); // some fixes addStyle(` *:where([class*="q-"], [class*="q-"]:not(body) *) { font-family: Roboto,-apple-system,Helvetica Neue,Helvetica,Arial,sans-serif; } *:not([class*="q-"], [class*="q-"]:not(body) *) { box-sizing: content-box; } *:where([class*="q-"]:not(body), [class*="q-"]:not(body) *), :after, :before { box-sizing: border-box; } p:where(:not([class*="q-"])) { margin: unset; } [class*="q-"]:not(body) .block:not(.plus-preserve-border) { border: none; } [class*="q-"]:not(body) .block { margin-bottom: 0; } `); const loadStyle = () => addStyle(` body { ${ $('link[href="/configs/article/page.css"]') ? 'font-family: 宋体,新细明体,Verdana,Arial,sans-serif;' : 'font: 12px/120% 宋体,Verdana,Arial,sans-serif;' } line-height: unset; } `); document.readyState === 'loading' ? $AEL(document, 'DOMContentLoaded', e => loadStyle()) : loadStyle(); } }, api: { dependencies: ['utils', 'debugging'], /** @typedef {Awaited>} api */ func() { /** @type {utils} */ const utils = require('utils'); /** @type {debugging} */ const debugging = require('debugging'); /** * 根据API返回的数字代码获取错误信息 * @param {number} errcode */ function getErrorInfo(errcode) { return ({ 0: '请求发生错误', 1: '成功(登陆、添加、删除、发帖)', 2: '用户名错误', 3: '密码错误', 4: '请先登录', 5: '已经在书架', 6: '书架已满', 7: '小说不在书架', 8: '回复帖子主题不存在', 9: '签到失败', 10: '推荐失败', 11: '帖子发送失败', 22: 'refer page 0' }) [errcode] ?? `未知错误 ${errcode}`; } /** * encode request data param for wenku8 api * @param {string} str * @returns {string} */ function encode(str) { return '&appver=1.13&request=' + btoa(str) + '&timetoken=' + (new Date().getTime()); } /** * @param {Object} detail * @param {string} detail.url * @returns {Promise} */ async function _request({ url }) { const { promise, resolve, reject } = Promise.withResolvers(); GM_xmlhttpRequest({ method: 'POST', url: 'http://app.wenku8.com/android.php', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 7.1.2; unknown Build/NZH54D)' }, data: encode(url), onload(response) { if (response.status !== 200) { const err = new Error('Network error while fetching api'); debugging.saveError({ type: 'api', error: err, info: { url } }); reject(response); } resolve(response.responseText); }, onerror(err) { reject(err); } }); return promise; } const request = utils.toQueued(_request, { max: 5, sleep: 0, queue_id: 'api_request' }); /** * 请求api并将返回字符串解析为XML文档 * 如果返回字符串无法解析为XML文档,则返回原始字符串 * @param {Parameters} args * @returns {Promise | string>} */ async function requestXML(...args) { const xml_source = await request(...args); try { return parseXML(xml_source); } catch (err) { return xml_source; } } /** * 将传入的字符串按照XML解析为XMLDocument,如果格式错误不能解析则显式报错 * @param {string} xml_source * @returns {XMLDocument} */ function parseXML(xml_source) { const parser = new DOMParser(); const xml = parser.parseFromString(xml_source, 'text/xml'); Assert(!xml.querySelector('parsererror'), 'parse error', Error); return xml; } /** * 获取书籍简要信息 * @param {Object} detail * @param {number | string} detail.aid - 文库书籍ID * @param {number} detail.lang - 文库语言代码,请使用 {@link LanguageCode} * @returns {Promise} */ async function getNovelShortInfo({ aid, lang }) { return requestXML({ url: `action=book&do=info&aid=${aid}&t=${lang}` }); } /** * 获取书籍信息(升级版) * 实测也就多了个tags数据 * @param {Object} detail * @param {number | string} detail.aid - 文库书籍ID * @param {number} detail.lang - 文库语言代码,请使用 {@link LanguageCode} * @returns {Promise} */ async function getNovelInfo({ aid, lang }) { return requestXML({ url: `action=book&do=bookinfo&aid=${aid}&t=${lang}` }); } /** * 获取书籍完整元信息 * @param {Object} detail * @param {number | string} detail.aid - 文库书籍ID * @param {number} detail.lang - 文库语言代码,请使用 {@link LanguageCode} * @returns {Promise} */ async function getNovelFullMeta({ aid, lang }) { return requestXML({ url: `action=book&do=meta&aid=${aid}&t=${lang}` }); } /** * 获取书籍完整简介 * @param {Object} detail * @param {number | string} detail.aid - 文库书籍ID * @param {number} detail.lang - 文库语言代码,请使用 {@link LanguageCode} * @returns {Promise} */ async function getNovelFullIntro({ aid, lang }) { return request({ url: `action=book&do=intro&aid=${aid}&t=${lang}` }); } /** * 获取书籍封面图片 * @param {Object} detail * @param {number | string} detail.aid - 文库书籍ID * @returns {Promise} */ async function getNovelCover({ aid }) { return request({ url: `action=book&do=cover&aid=${aid}` }); } /** * 获取书籍目录 * @param {Object} detail * @param {number | string} detail.aid - 文库书籍ID * @param {number} detail.lang - 文库语言代码,请使用 {@link LanguageCode} * @returns {Promise} */ async function getNovelIndex({ aid, lang }) { return requestXML({ url: `action=book&do=list&aid=${aid}&t=${lang}` }); } /** * 获取某一章节内容 * @param {Object} detail * @param {number | string} detail.aid - 文库书籍ID * @param {number | string} detail.cid - 文库章节ID * @param {number} detail.lang - 文库语言代码,请使用 {@link LanguageCode} * @returns {Promise} */ async function getNovelContent({ aid, cid, lang }) { return request({ url: `action=book&do=text&aid=${aid}&cid=${cid}&t=${lang}` }); } /** * 获取用户信息 * @returns {Promise} */ async function getUserInfo() { return requestXML({ url: 'action=userinfo' }); } /** * 用户登录,可选通过用户名或邮箱登录 * 也许需要注意:纯http请求+明文密码或许是安全性的地狱 * @param {string} username - username or email * @param {string} password * @param {boolean} [useEmail=false] */ async function login(username, password, useEmail = false) { return request({ url: `action=${useEmail ? 'loginemail' : 'login'}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}` }); } /** * 退出登录 */ async function logout() { return request({ url: 'action=logout' }); } return { getErrorInfo, encode, request, requestXML, getNovelShortInfo, getNovelInfo, getNovelFullMeta, getNovelFullIntro, getNovelCover, getNovelIndex, getNovelContent, getUserInfo, login, logout, }; } }, sidepanel: { desc: '工具栏按钮', dependencies: ['dependencies', 'debugging', 'utils'], detectDom: 'body', /** @typedef {Awaited>} sidepanel */ func() { /** @type {debugging} */ const debugging = require('debugging'); /** @type {utils} */ const utils = require('utils'); let instance; /** * @callback ButtonCallback * @param {PointerEvent} e */ /** * 按钮类型,不同类型按钮会通过不同外观给予用户不同视觉提示 * @typedef {'universal' | 'functional'} ButtonType */ /** * 按钮数据 * @typedef {Object} Button * @property {string} id - 按钮id,需全局唯一 * @property {string} label * @property {string} icon * @property {ButtonType} [type='functional'] * @property {ButtonCallback} callback - 按钮点击回调,带点击事件 * @property {number} index - button的位置,按钮排序顺序:上 <== -1 -2 -3 ... 3 2 1 <== 下 */ const container = $CrE('div'); container.innerHTML = `
`; document.body.append(container); addStyle(` .plus-sidepanel { position: fixed; right: 2em; bottom: 2em; } `); const app = Vue.createApp({ data() { return { /** @type {Button[]} */ buttons: [], expanded: false, }; }, computed: { ButtonColors() { return { 'universal': 'primary', 'functional': 'secondary', }; } }, methods: { /** * 按钮被点击: * 1. 阻止侧边栏自动折叠 * 2. 带错误处理地执行按钮回调 * @param {PointerEvent} e * @param {ButtonCallback} callback */ onClick(e, callback) { this.expanded = true; debugging.callWithErrorHandling(callback, this, [e]); }, }, mounted() { // Vue作用域外使用instance引用this // 本作用域依然属于Vue作用域内,按照原则使用that const that = instance = this; // 点击侧边栏以外的文档任意位置,隐藏侧边栏 $AEL(document, 'click', e => { if (!container.contains(e.target)) { that.expanded = false; } }); } }); app.use(Quasar); app.mount(container); /** * 注册一个新按钮到侧边栏 * 每次有新按钮注册或已有按钮移除都会重新排序所有按钮,保证顺序符合index升序 * @param {Button} button */ function registerButton(button) { // 检查id是否全局唯一 Assert( !hasButton(button.id), `duplicate button id ${escJsStr(button.id)}` ); // 先克隆button对象,防止后续外部代码修改产生影响 button = Object.assign({}, button); // 补充可选属性默认值 !button.type && (button.type = 'functional'); // 添加到UI中 instance.buttons.push(button); // 重新排序 instance.buttons.sort((btn1, btn2) => { // 上 <== -1 -2 -3 ... 3 2 1 <== 下 const [i1, i2] = [btn1.index, btn2.index]; if (i1 * i2 > 0) { // [1, 2, 3, ...] | [..., -3, -2, -1] return btn1.index - btn2.index; } else { // positive, negative return i1 < 0 ? 1 : -1; } }); } /** * 从侧边栏移除一个按钮 * @param {string} id - 按钮id * @returns {Button} 被移除的按钮 */ function removeButton(id) { // 检查按钮是否存在 Assert( hasButton(id), `No button found with id ${escJsStr(id)}` ); // 移除按钮 const index = instance.buttons.findIndex(btn => btn.id === id); return index >= 0 ? instance.buttons.splice(index, 1) : null; } /** * 更新已注册按钮的属性 * @param {string} id - 按钮id * @param {Partial