您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
轻小说文库全方位体验改善,涵盖阅读、下载、书架、推荐、书评、账号、页面个性化等各种方面,你能想到的这里都有。没有?欢迎提出你的点子。
// ==UserScript== // @name 轻小说文库+ // @namespace https://gf.qytechs.cn/users/667968-pyudng // @version 2.alpha.7.2 // @description 轻小说文库全方位体验改善,涵盖阅读、下载、书架、推荐、书评、账号、页面个性化等各种方面,你能想到的这里都有。没有?欢迎提出你的点子。 // @author PY-DNG // @license GPL-3.0-or-later // @homepageURL https://gf.qytechs.cn/scripts/539514 // @supportURL https://gf.qytechs.cn/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.gf.qytechs.cn/scripts/456034/1606773/Basic%20Functions%20%28For%20userscripts%29.js // @require https://update.gf.qytechs.cn/scripts/471280/1247074/URL%20Encoder.js // @require https://fastly.jsdelivr.net/npm/[email protected]/Sortable.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js // @require https://fastly.jsdelivr.net/npm/[email protected]/ejs.min.js // @require https://fastly.jsdelivr.net/npm/[email protected]/dist/jepub.min.js // @require https://fastly.jsdelivr.net/npm/[email protected]/dist/confetti.browser.min.js // @resource vue-js https://unpkg.com/[email protected]/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/[email protected]/dist/quasar.prod.css // @resource quasar-js https://fastly.jsdelivr.net/npm/[email protected]/dist/quasar.umd.prod.js // @resource vue-js-bak https://fastly.jsdelivr.net/npm/[email protected]/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/[email protected]/dist/quasar.prod.css // @resource quasar-js-bak https://unpkg.com/[email protected]/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 // ==/UserScript== const { Component } = require('react'); 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: '修改后其他页面需要重新加载以生效', Component: { SelectImage: '选择图片', PleaseChoose: '请选择', }, Tabs: { ModuleSettings: '模块设置', About: '关于', AboutTab: '关于脚本', FAQ: '常见问题', }, About: { Version: `版本: ${ GM_info.script.version }`, Author: `作者: ${ GM_info.script.author }`, Homepage: `主页: <a href="${ GM_info.script.homepageURL }">Greasyfork</a>`, get TechnicalNote() { return `${ GM_info.script.name } 使用自行编写的模块加载系统驱动,由 ${ Object.keys(functions).length } 个模块共同组成;在UI方面,使用了<a href="cn.vuejs.org" target="_blank">Vue.js</a> 和 <a href="https://quasar.dev/" target="_blank">Quasar</a> 框架,并以 <a href="https://fonts.google.com/icons" target="_blank">Material Symbols & Icons</a> 作为图标库。`; }, FAQ: [{ Q: '为什么模块的设置时常消失?', A: '模块只会在需要它的功能的页面运行,而在其他页面上由于模块不会运行,其设置项也不会在这些不运行的页面上存在。比如,「书评增强」模块的设置只会在书评页面出现。', }], }, }, Styling: { Settings: { Title: '页面主题', Enabled: '启用主题功能', EnabledCaption: '未启用时,使用原版文库界面', }, }, 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', } }, UserReview: { CheckUserReviews: '用户书评', }, 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: `<p>本书轻小说文库链接:<a href="{URL}">{URL}</a><br>Epub电子书由<a href="${GM_info.script.homepageURL}">${GM_info.script.name}</a>合成。</p><p>本资源仅供试读,如喜爱本书,请购买正版。</p>`, 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: '本地图片', ImageFit: '图片缩放与裁剪', ImageFitOptions: [{ label: '放大图片到宽或者高的其中任何一条边能够填满屏幕,剩余不能填满屏幕的部分将用底色填充(底色取决于浏览器),不改变图片宽高比例', brief: '包含在页面内', value: 'contain', }, { label: '放大图片到能完全覆盖整个页面的最小尺寸,溢出屏幕的部分将被裁剪,不改变图片宽高比例', brief: '覆盖整个页面', value: 'cover', }, { label: '缩放图片到完全适合网页页面大小,必要时改变图片的宽高比(允许将图片压扁或拉长)', brief: '缩放到页面尺寸', value: 'fill', }, { label: '保持图片自身原始大小与宽高比,无论是否适合页面', brief: '保持原始尺寸', value: 'none', }], MaskOpacity: '图片遮罩层不透明度', Color: '颜色', }, }, OpenLastPage: { OpenLastPageButton: '[打开尾页]', }, Blocking: { BlockUser: '屏蔽用户', UnBlockUser: '解除屏蔽', UserBlocked: '该用户已被屏蔽' }, } }, /** * @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: '文库导航 / 中转站', }], // 书评图片重试缩放间隔 ReviewResizeInterval: 500, // 自适应高度编辑器的最大和最小高度 EditorHeight: { Min: 150, Max: 500, }, }, }; const functions = { utils: { /** @typedef {Awaited<ReturnType<typeof functions.utils.func>>} 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<typeof FunctionLoader.FuncPool> }} */ function loadFuncInNewPool(pool_funcs, GM_funcs={}) { /** * @param {InstanceType<typeof FunctionLoader.FuncPool>} 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<string, any>} 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<string>} */ 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<Document>} */ 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<Blob>} */ 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<Awaited<task>>} */ 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<ReturnType<typeof functions.debugging.func>>} 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<typeof FunctionLoader.FuncPool>} 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<F>} */ 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<ReturnType<typeof functions.logger.func>>} 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<typeof console.log>} 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', }); Quasar.LoadingBar.setDefaults({ hijackFilter(url) { return false; } }); // 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<ReturnType<typeof functions.api.func>>} 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<string>} */ 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<typeof request>} args * @returns {Promise<ReturnType<typeof parseXML> | 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<XMLDocument>} */ 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<XMLDocument>} */ 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<XMLDocument>} */ 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<string>} */ 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<string>} */ 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<XMLDocument>} */ 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<string>} */ async function getNovelContent({ aid, cid, lang }) { return request({ url: `action=book&do=text&aid=${aid}&cid=${cid}&t=${lang}` }); } /** * 获取用户信息 * @returns {Promise<XMLDocument>} */ 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<ReturnType<typeof functions.sidepanel.func>>} 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 = ` <div class="plus-sidepanel q-mt-md"> <q-fab square external-label label="${ CONST.Text.SidePanel.PanelShowHide }" label-position="left" vertical-actions-align="center" color="primary" icon="keyboard_arrow_up" direction="up" padding="0.75em" label-style="font-size: 1em; line-height: 1.715em;" v-model="expanded" > <q-fab-action v-for="button of buttons" external-label square padding="0.75em" :color="ButtonColors[button.type]" label-position="left" @click="onClick.call(this, $event, button.callback)" :icon="button.icon" :label="button.label" label-style="font-size: 1em; line-height: 1.715em;" ></q-fab-action> </q-fab> </div> `; 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<Button>} props - 需要修改的按钮属性-值 */ function updateButton(id, props) { // 检查按钮是否存在 Assert( hasButton(id), `No button found with id ${escJsStr(id)}` ); // 更新按钮 const button = instance.buttons.find(btn => btn.id === id); Object.assign(button, props); } /** * 检查指定id对应的按钮是否存在 * @param {string} id * @returns */ function hasButton(id) { return instance.buttons.some(btn => btn.id === id); } // 注册(不可用)一些通用按钮 registerButton({ id: 'JumpToTop', label: CONST.Text.SidePanel.GotoTop, icon: 'keyboard_arrow_up', type: 'universal', index: -1, callback() { const elms = [document.body.parentElement, $('#content'), $('#contentmain')]; for (const elm of elms) { elm && elm.scrollTo && elm.scrollTo({ left: elm.scrollLeft, top: 0, behavior: 'smooth' }); } } }); registerButton({ id: 'JumpToBottom', label: CONST.Text.SidePanel.GotoBottom, icon: 'keyboard_arrow_down', type: 'universal', index: -2, callback() { const elms = [document.body.parentElement, $('#content'), $('#contentmain')]; for (const elm of elms) { elm && elm.scrollTo && elm.scrollTo({ left: elm.scrollLeft, top: elm.scrollHeight, behavior: 'smooth' }); } } }); registerButton({ id: 'RefreshPage', label: CONST.Text.SidePanel.Refresh, icon: 'refresh', type: 'universal', index: -3, callback() { const location = utils.window.top.location; if (location.href.includes('#')) { const url = new URL(location.href); url.searchParams.set('_t', Date.now().toString()); location.replace(url); } else { location.replace(location.href); } } }); return { registerButton, removeButton, updateButton, hasButton, }; } }, settings: { desc: '分组展示的设置界面(仅界面UI)', dependencies: ['dependencies', 'debugging'], params: ['GM_setValue', 'GM_getValue'], /** @typedef {Awaited<ReturnType<typeof functions.settings.func>>} settings */ async func(GM_setValue, GM_getValue) { /** @type {debugging} */ const debugging = require('debugging'); /** * 代表一个设置组,同一设置组内的设置将会显示在同一板块/标签页中 * @typedef {Object} SettingsGroup * @property {SettingItem[]} items - 组内全部设置项 * @property {string} label - 组名称,用于在UI中展示 * @property {string} id - 组id标识,全局唯一 */ /** * 代表一条设置项 * @typedef {Object} SettingItem * @property {string} label - 设置项名称 * @property {string} type - 设置项类型 * @property {string | null} [caption] - 设置项副标题,可以省略;为假值时不渲染副标题元素 * @property {string} key - 用作settings 读/写对象的prop名称,也用作v-model值 * @property {string} [help] - 在用户编辑此项设置时,显示的帮助文档 * @property {boolean | 'page'} [reload] - 修改设置后是否需要重载页面才能生效,false: 实时生效,true: 需要重载,'page': 其他页面需要重载;默认为false * @property {{label: string, value: string}[]} [options] - select类型设置的options * @property {{min: number, max: number, step: number}} [range] - 滑块类型的最小/最大值、步长 * @property {function} [callback] - button类型设置项的按钮回调;以及其他任何类型的设置值在当前页面的UI中被改变的回调 * @property {string} [button_label] - button类型设置项的按钮文本 * @property {string} [button_icon] - button类型设置项的按钮图标 * @property {getter} get - 需要显示设置内容到UI中时,实际执行读取设置操作的函数 * @property {setter} set - 用户在UI中更改设置时,实际执行保存设置操作的函数 */ /** * 用户在UI中更改设置时,实际执行保存设置操作的函数 * @callback setter * @param {any} val */ /** * 需要显示设置内容到UI中时,实际执行读取设置操作的函数 * @callback getter * @returns {any} */ // 创建UI const Settings = CONST.Text.Settings; const container = $CrE('div'); container.innerHTML = ` <q-dialog v-model="visible" full-width full-height class="plus-settings"> <q-layout container view="hHh Lpr fFf"> <q-header bordered> <q-toolbar> <q-btn flat round icon="menu" style="background: transparent;" @click="$refs.drawer.toggle()"></q-btn> <q-toolbar-title>${ Settings.DialogTitle }</q-toolbar-title> <q-btn flat round icon="close" style="background: transparent;" @click="visible = false"></q-btn> </q-toolbar> <q-tabs align="left" v-model="header_tab" > <q-tab name="settings" label="${ Settings.Tabs.ModuleSettings }" ></q-tab> <q-tab name="about" label="${ Settings.Tabs.About }" ></q-tab> </q-tabs> </q-header> <q-drawer show-if-above bordered side="left" :breakpoint="drawer_breakpoint" ref="drawer" > <!-- 根据header tab值确定drawer内容 --> <q-tab-panels v-model="header_tab"> <q-tab-panel name="settings" class="q-pa-none"> <q-tabs v-model="tab" indicator-color="primary" active-bg-color="active" vertical > <q-tab v-for="group of groups" no-caps :name="group.id" :label="group.label" ></q-tab> </q-tabs> </q-tab-panel> <q-tab-panel name="about" class="q-pa-none"> <q-tabs v-model="about_tab" indicator-color="primary" active-bg-color="active" vertical > <q-tab no-caps name="about" label="${ Settings.Tabs.AboutTab }" ></q-tab> <q-tab no-caps name="faq" label="${ Settings.Tabs.FAQ }" ></q-tab> </q-tabs> </q-tab-panel> </q-tab-panels> </q-drawer> <q-page-container> <q-page> <q-card square class="settings-container q-pa-md"> <q-tab-panels v-model="header_tab"> <!-- "设置"选项卡:设置项列表 --> <q-tab-panel name="settings" class="q-pa-none"> <q-list v-if="header_tab === 'settings'"> <q-item v-if="current_group" v-for="item of current_group.items" tag="label"> <q-item-section> <q-item-label>{{ item.label }}</q-item-label> <q-item-label caption v-if="item.caption">{{ item.caption }}</q-item-label> <q-item-label caption v-if="item.reload === true && modified[item.key]" class="text-warning">${ CONST.Text.Settings.NeedsReload }</q-item-label> <q-item-label caption v-if="item.reload === 'page' && modified[item.key]" class="text-warning">${ CONST.Text.Settings.OtherPageNeedsReload }</q-item-label> </q-item-section> <q-item-section avatar> <!-- 布尔值类型: 开关 --> <q-toggle v-if="item.type === 'boolean'" color="primary" v-model="settings[item.key]" @update:model-value="val => onSettingUpdate(item, val)" ></q-toggle> <!-- 字符串类型: 输入框 --> <q-input v-else-if="item.type === 'string'" v-model="settings[item.key]" @focus="tooltips[item.key] = true" @blur="tooltips[item.key] = false" @keydown="e => e.stopPropagation()" @update:model-value="val => onSettingUpdate(item, val)" ></q-input> <!-- 数字范围类型:滑块 --> <q-slider v-else-if="item.type === 'range'" :max="item.range.max" :min="item.range.min" :step="item.range.step" style="width: 10em;" v-model="settings[item.key]" @update:model-value="val => onSettingUpdate(item, val)" ></q-slider> <!-- select类型: 选择器 --> <q-select v-else-if="item.type === 'select'" :options="item.options" v-model="settings[item.key]" emit-value map-options @update:model-value="val => onSettingUpdate(item, val)" ></q-select> <!-- choose类型: 单选(更复杂的选择器) --> <p-choose v-else-if="item.type === 'choose'" :options="item.options" v-model="settings[item.key]" @update:model-value="val => onSettingUpdate(item, val)" ></p-choose> <!-- 颜色类型: 颜色选择器 --> <p-color v-else-if="item.type === 'color'" v-model="settings[item.key]" @update:model-value="val => onSettingUpdate(item, val)" ></p-color> <!-- 本地图片类型: 本地图片选择器 --> <p-image-select v-else-if="item.type === 'image'" v-model="settings[item.key]" @update:model-value="val => onSettingUpdate(item, val)" ></p-image-select> <!-- 按钮类型: 按钮 --> <q-btn v-else-if="item.type === 'button'" :label="item.button_label" :icon="item.button_icon" @click="item.callback" flat ></q-btn> <!-- 浮动提示 --> <q-tooltip v-if="item.help" v-model="tooltips[item.key]" :no-parent-event="item.type === 'string'" v-html="item.help" style="font-size: 1em;" ></q-tooltip> </q-item-section> </q-item> </q-list> </q-tab-panel> <!-- "关于"选项卡 --> <q-tab-panel name="about" class="q-pa-none text-body1"> <q-tab-panels v-model="about_tab"> <!-- 关于 --> <q-tab-panel name="about" class="q-pa-none"> <div class="text-h5 q-mb-md">${ GM_info.script.name }</div> <div class="text-subtitle1 q-my-sm">${ GM_info.script.description }</div> <div class="q-my-sm">${ Settings.About.Version }</div> <div class="q-my-sm">${ Settings.About.Author }</div> <div class="q-my-sm">${ Settings.About.Homepage }</div> <div class="q-my-sm"> ${ Settings.About.TechnicalNote } <span class="text-weight-bold" style="cursor: pointer;" @click="cool">Cool!</span> </div> </q-tab-panel> <!-- 常见问题 --> <q-tab-panel name="faq" class="q-pa-none"> <q-expansion-item v-for="faq of FAQ" :label="faq.Q" class="text-h6" > <div class="text-body1 q-pa-md">{{ faq.A }}</div> </q-expansion-item> </q-tab-panel> </q-tab-panels> </q-tab-panel> </q-tab-panels> </q-card> </q-page> </q-page-container> </q-layout> </q-dialog> `; let instance; const app = Vue.createApp({ data() { return { /** * 存储设置项信息 * @type {SettingsGroup[]} */ groups: [], tab: '', header_tab: 'settings', about_tab: 'about', visible: false, /** * 存储全部设置内容的变量 * @type {Record<string, Record<string, any>>} */ all_settings: {}, /** * 记录设置项自从设置界面创建起,是否被修改过的变量 * @type {Record<string, Record<string, boolean>>} */ all_modified: {}, FAQ: Settings.About.FAQ, }; }, computed: { /** @type {SettingsGroup} */ current_group() { return this.groups.find(g => g.id === this.tab); }, /** * 读写当前UI上的active tab对应的group的设置 * @type {Record<string, any>} */ settings() { return this.all_settings[this.tab]; }, modified() { return this.all_modified[this.tab]; }, /** * 当前UI上的active tab对应的group的key - help文档对照表对象 * @type {Record<string, string>} */ tooltips() { return this.current_group.items.reduce((tips, item) => { tips[item.key] = item.help; return tips; }, {}); }, drawer_breakpoint() { return debugging.script_debug ? 880 : 1023; }, }, watch: { // 监听设置组变化 groups: { async handler(val, old_val) { // 当从没有设置组到有一个设置组加入时,自动将此设置组设为active tag if (val.length && !this.tab) { this.tab = val[0].id; } // 自动将新加入的设置组加入到this.all_settings和this.all_modified中 for (const group of val) { // 无论是否已有此组都强制更新此组,因为组内设置项可能变化 const setting = {}; await Promise.all(group.items.map(async item => { item.get && (setting[item.key] = await Promise.resolve(item.get())); return setting; })); this.all_settings[group.id] = setting; this.all_modified[group.id] = group.items.reduce((modified, item) => { modified[item.key] = false; return modified; }, {}); } }, deep: true, }, }, methods: { /** * @param {SettingItem} item * @param {any} val */ async onSettingUpdate(item, val) { // 回调外部setter,保存设置 await Promise.resolve(item.set(val)); // 如果有callback,回调callback if (item.callback) { await Promise.resolve(item.callback()); } // 记录此项已被修改过 this.modified[item.key] = true; }, /** * Make some confetti. Congratulations! */ cool() { let count = 200; let defaults = { origin: { y: 0.7 }, zIndex: 8000, }; function fire(particleRatio, opts) { confetti({ ...defaults, ...opts, particleCount: Math.floor(count * particleRatio), }); } fire(0.25, { spread: 26, startVelocity: 55, }); fire(0.2, { spread: 60, }); fire(0.35, { spread: 100, decay: 0.91, scalar: 0.8, }); fire(0.1, { spread: 120, startVelocity: 25, decay: 0.92, scalar: 1.2, }); fire(0.1, { spread: 120, startVelocity: 45, }); }, }, mounted() { instance = this; }, }); document.body.append(container); app.use(Quasar); // 注册(不可用)自定义设置项表单组件 // 本地图片选择器 app.component('p-image-select', { name: 'PImageSelect', props: ['modelValue'], emits: ['update:modelValue'], template: ` <q-img v-if="file" :src="img_src" style="width: 10em;" ></q-img> <q-btn v-show="!file" label="${ Settings.Component.SelectImage }" @click="selectImage" flat ></q-btn> `, computed: { // v-model file: { get() { return this.modelValue; }, set(file) { this.$emit('update:modelValue', file); } }, // 图片src img_src(_, old_src) { old_src && URL.revokeObjectURL(old_src); return URL.createObjectURL(this.modelValue); }, }, methods: { /** * 用户选择图片 */ selectImage() { const that = this; $$CrE({ tagName: 'input', props: { type: 'file', }, listeners: [['change', async e => { /** @type {HTMLInputElement} */ const input = e.target; const file = input.files[0]; that.file = file; }]] }).click(); }, } }); // 颜色选择器 app.component('p-color', { name: 'PColor', props: ['modelValue'], emits: ['update:modelValue'], template: ` <q-input v-model="color" :rules="['anyColor']" > <template v-slot:append> <q-icon name="colorize" class="cursor-pointer"> <q-popup-proxy cover transition-show="scale" transition-hide="scale"> <q-color v-model="color"></q-color> </q-popup-proxy> </q-icon> </template> </q-input> `, computed: { color: { get() { return this.modelValue; }, set(color) { this.$emit('update:modelValue', color); }, }, }, }); // 列表单选类型 app.component('p-choose', { name: 'PChoose', props: ['modelValue', 'options'], emits: ['update:modelValue'], template: ` <q-btn :label="brief" icon-right="keyboard_arrow_down" flat> <q-popup-proxy> <q-list> <q-item v-for="option of options" tag="label"> <q-radio v-model="value" :val="option.value" :label="option.label" ></q-radio> </q-item> </q-list> </q-popup-proxy> </q-btn> `, computed: { value: { get() { return this.modelValue; }, set(val) { this.$emit('update:modelValue', val); }, }, brief() { return this.options.find(o => o.value === this.value)?.brief ?? Settings.Component.PleaseChoose; } }, }); // 挂载Vue app.mount(container); // 设置界面样式 addStyle(` .plus-settings .settings-container { position: absolute; width: 100%; height: 100%; } `); // 注册(不可用)侧边栏设置按钮 require('sidepanel', true).then( /** @param {sidepanel} sidepanel */ sidepanel => sidepanel.registerButton({ id: 'settings.show', label: CONST.Text.Settings.DialogTitle, icon: 'settings', type: 'universal', index: -4, callback() { instance.visible = true; } }) ); /** * 注册(不可用)新的设置组 * @param {SettingsGroup} group - 设置组对象 */ function registerGroup({ id, label, items = [] }) { /** @type {SettingsGroup[]} */ const groups = instance.groups; Assert(groups.every(g => g.id !== id), `duplicate id ${escJsStr(id)}`, TypeError); groups.push({ id, label, items }); } /** * 注册(不可用)新的设置项 * @param {string} id - 设置组id * @param {SettingItem | SettingItem[]} items */ function registerSettings(id, items) { items = Array.isArray(items) ? items : [items]; /** @type {SettingsGroup[]} */ const groups = instance.groups; const group = groups.find(g => g.id === id); Assert(group, `Settings group with id ${escJsStr(id)} not exist, call registerGroup first.`, TypeError); group.items.push(...items); } /** * 主动更新设置项的值到设置UI中 * @param {string} group_id - 设置组id * @param {string} item_key - 设置项key * @param {any} val - 设置项的新值 */ function update(group_id, item_key, val) { instance.all_settings[group_id][item_key] = val; } return { registerGroup, registerSettings, update, /** 用于导出JSDoc类型,无实际作用 */ _types: { /** @type {SettingItem} */ SettingItem: {}, /** @type {SettingsGroup} */ SettingsGroup: {}, }, }; } }, configs: { desc: '模块配置管理器,对settings和脚本存储空间的高级封装;分模块管理配置存储与设置界面,跨页面实例同步配置、功能与设置界面;负责模块的 设置界面 - 设置存储 - 模块功能 间的统一调度', dependencies: ['settings'], /** @typedef {Awaited<ReturnType<typeof functions.configs.func>>} configs */ async func() { /** @type {settings} */ const settings = require('settings'); /** @typedef {typeof settings._types.SettingItem} SettingItem */ /** @typedef {typeof settings._types.SettingsGroup} SettingsGroup */ /** * 模块监听器函数 * @callback update_callback * @param {string} key - 设置项key * @param {any} old_val - 设置项旧值 * @param {any} new_val - 设置项新值 * @param {boolean} remote - 表示本次更改是否来源于另一页面的脚本实例 */ /** * 代表模块监听器的对象 * @typedef {{ id: symbol, callback: update_callback }} config_listener */ /** * 代表一个模块的配置 * @typedef {Object} Config * @property {string} id - 全局唯一模块id * @property {typeof GM_addValueChangeListener || null} GM_addValueChangeListener - 用于监听设置项内容变化的GM函数 * @property {SettingItem[]} items - 注册(不可用)到settings界面中的设置项数组 * @property {string} label - 显示在settings界面中的模块名称 * @property {Record<string, config_listener[]>} listeners - 监听模块设置内容变化的全部监听器 */ /** @type {Record<string, Config>} */ const configs = {}; /** * 注册(不可用)一个新模块 * 为模块提供以下功能: * - 注册(不可用)设置项到settings界面中 * - 在跨页面跨实例的配置存储更新中: * - 提供更新回调接口,以供模块将更改应用于实际功能 * - 自动将新配置值同步到settings界面中 * @param {string} id - 全局唯一模块id * @param {Object} options * @param {typeof GM_addValueChangeListener} [options.GM_addValueChangeListener] - 用于监听设置项内容变化的GM函数 * @param {SettingItem | SettingItem[]} [options.items=[]] - 注册(不可用)到settings界面中的设置项数组 * @param {string} options.label - 显示在settings界面中的模块名称 * @param {Record<string, update_callback[]> | update_callback} [options.listeners={}] - 监听设置值变化的监听器, 可以为一个key-listener格式的对象用于分别监听多个设置项,也可以为一个listener函数用于监听全部模块设置项变化 */ function registerConfig(id, { GM_addValueChangeListener = null, items = [], label, listeners = {}, }) { // 记录此模块 const config = configs[id] = { id, items, label, listeners: {}, GM_addValueChangeListener, }; // 注册(不可用)设置项 items = Array.isArray(items) ? items : [items]; settings.registerGroup({ id, label }); registerSettings(id, items); // 注册(不可用)监听器 registerUpdateCallback(id, listeners); } /** * 注册(不可用)设置项: * - 注册(不可用)到settings界面中 * - 为每个设置项自动监听变化: * - 自动同步到设置界面中 * - 执行回调 * @param {string} id - 全局唯一模块id * @param {SettingItem | SettingItem[]} items - 需注册(不可用)的设置项 * @param {typeof GM_addValueChangeListener} [GM_addValueChangeListener] - 本次注册(不可用)的设置项,监听其值变化时所使用的GM函数,如不提供则使用模块注册(不可用)时提供的GM函数 */ function registerSettings(id, items=[], GM_addValueChangeListener=null) { items = Array.isArray(items) ? items : [items]; const config = configs[id]; // 注册(不可用)设置UI settings.registerSettings(id, items); // 用于监听设置项变化的GM函数 GM_addValueChangeListener = GM_addValueChangeListener ?? config.GM_addValueChangeListener ?? null; // 此次调用和此前注册(不可用)中,至少要提供一个GM_addValueChangeListener,否则无法监听设置项内容变化 Assert(GM_addValueChangeListener, 'configs.registerSettings: GM_addValueChangeListener not provided when adding value change listeners'); // 监听每个设置项内容变化 items.forEach(item => { // 创建此设置项的监听器数组 config.listeners[item.key] = []; // 监听设置项内容变化 GM_addValueChangeListener( item.key, (key, old_val, new_val, remote) => { // 同步到设置UI settings.update(id, key, new_val); // 模块回调 configs[id].listeners[key].forEach(cb => cb.callback(key, old_val, new_val, remote)); } ); }); } /** * 注册(不可用)设置内容更新回调 * @param {string} id - 监听目标模块id * @param {Record<string, update_callback> | update_callback} listener - 回调函数,可以为一个key-listener格式的对象用于分别监听多个设置项,也可以为一个listener函数用于监听全部模块设置项变化 * @returns {() => void} 用于取消回调的方法,调用后不再监听本次注册(不可用)的所有相关设置项内容更新 */ function registerUpdateCallback(id, callback) { const config = configs[id]; if (typeof callback === 'function') { const unregisters = Reflect.ownKeys(config.listeners).map(key => register(key, callback)); return () => unregisters.forEach(unregister => unregister()); } else { const unregisters = Object.entries(callback).map(([key, callback]) => register(key, callback)); return () => unregisters.forEach(unregister => unregister()); } /** * 对模块内的一项设置注册(不可用)内容更新回调 * @param {string} key * @param {update_callback} callback * @returns {() => void} 用于取消回调的方法,调用后不再监听内容更新 */ function register(key, callback) { const callback_id = Symbol('Configs.UpdateCallbackId'); config.listeners[key].push({ id: callback_id, callback }); return () => config.listeners[key].splice( config.listeners[key].findIndex(cb => cb.id === callback_id), 1 ) } } return { registerConfig, registerSettings, registerUpdateCallback, }; }, }, _styling: { desc: '文库网页样式管理器', disabled: true, detectDom: ['head', 'body'], dependencies: ['utils', 'configs'], params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { /** @type {utils} */ const utils = require('utils'); /** @type {configs} */ const configs = require('configs'); // 带默认值的GM_getValue GM_getValue = utils.defaultedGet({ enabled: false, theme: 'darkmode', /** * 存储用户自定义主题 * @type {Record<string, string>} */ themes: {}, }, GM_getValue); /** @typedef {typeof FunctionLoader._types.checker} checker */ /** @typedef {{ checkers: [checker | checker[]], content: string }} Style */ /** * 将主题色应用到页面的CSS * @type {Record<string, Style>} */ const Styles = { block: { content: ` /* 标题、内容和脚注 */ .plus-styled .blocktitle { border-color: var(--plus-background-title); } .plus-styled :is(#left, #right, #centers, *) .blocktitle>:is(.txt, .txtr) { background-color: var(--plus-background-3); line-height: 27px; padding-top: 0; } .plus-styled :is(#left, #right, *) .blockcontent { background-color: var(--plus-background-1) } .plus-styled :is(#left, #right, *) .blocknote { background-color: var(--plus-background-2); } /* 特定类型内容 */ .plus-styled :is(#left, #right, #centers, *) :is(.blocktitle, .blocktitle *, .ultop li) { color: var(--plus-text-title) } /* 边框 */ .plus-styled .block { border: 1px solid var(--plus-primary); } .plus-styled :is(.blockcontent, .blocknote) { border-color: var(--plus-primary); } .plus-styled .block :is(.ultop li, .ultops li) { border-bottom: 1px dashed var(--plus-primary); } `, }, book: { checkers: [{ type: 'regpath', value: /\/book\/\d+\.htm/ }, { type: 'regpath', value: /\/modules\/article\/articleinfo\.php/ }], content: ` /* 需要补充基层颜色的各区域 */ .plus-styled :is(:is(#left, #right, #centers) .blockcontent, .blockcontent, .odd, .even) { background-color: var(--plus-background-1); color: var(--plus-text-1); } /* 表头 */ .plus-styled table.grid:not(form table) tr:first-of-type > td:nth-of-type(2n+1) { background-color: var(--plus-background-2) !important; } /* 表行 */ .plus-styled table.grid td { background-color: var(--plus-background-1) !important; } /* 《文学少女》吐槽吧,不吐不快! */ .plus-styled table.grid:not(form table, #content .main > table:first-of-type) tr:first-of-type > td:first-of-type { color: var(--plus-text-title); } .plus-styled fieldset { border: 2px solid var(--plus-primary); } .plus-styled :is(table.grid, table.grid td, table.grid caption, .gridtop) { border: 1px solid var(--plus-primary); } `, }, bookindex: { checkers: [{ type: 'regpath', value: /^\/novel\/\d+\/\d+\/index\.html?$/ }, { type: 'path', value: '/modules/article/reader.php' }], content: ` .plus-styled :is(.css, .vcss, .ccss) { background-color: var(--plus-background-1); color: var(--plus-text-1); } .plus-styled #headlink { border-bottom: 1px solid var(--plus-primary); border-top: 1px solid var(--plus-primary); } .plus-styled :is(.css, .vcss, .ccss) { border: 1px solid var(--plus-primary); border-collapse: collapse; } `, }, common: { content: ` /* 通用页面样式 */ body.plus-styled:not(#stonger-than-quasar) { background: var(--plus-page-bg); color: var(--plus-text); } `, }, dialog: { content: ` .plus-styled #dialog { color: var(--plus-text-1); background-color: var(--plus-background-1); border: 5px solid var(--plus-primary); } .plus-styled #dialog a[onclick="closeDialog()"] { border: 1px solid var(--plus-primary) !important; outline: thin solid var(--plus-primary) !important; } `, }, element: { content: ` .plus-styled :is(.even, .odd) { background-color: var(--plus-background-1); } .plus-styled table.grid td { background-color: var(--plus-background-1) !important; } .plus-styled :is(input:not([type]:not([type="text"], [type="number"], [type="file"], [type="password"])), textarea, .plus_list_item, button):not([class*="q-"]:not(body) *) { background-color: var(--plus-background-2); color: var(--plus-text-input); } .plus-styled :is(.button, input[type="button"]) { color: var(--plus-text-2); background-color: var(--plus-background-2); } .plus-styled select { color: var(--plus-text-2); background-color: var(--plus-background-2); } .plus-styled :is(.hottext, a.hottext) { color: var(--plus-text-hot); } .plus-styled :is(.button, select, textarea, input:not(.plus_list_item>input, .UBB_ColorList input), .plus_list_item):not(:disabled, [class*="q-"]:not(body) *) { border: 1px solid var(--plus-primary); } .plus-styled :is(input, textarea, button):disabled { border: 1px solid var(--plus-border-disabled); } .plus-styled a { color: var(--plus-text-link); } .plus-styled a:hover { color: var(--plus-text-link-hover); } .plus-styled a:is(.ultop li a, .poptext, a.poptext, .ultops li a) { color: var(--plus-text-hot); } .plus-styled :is(table.grid caption, .gridtop, table.grid th, .head) { border: 1px solid var(--plus-primary); background: var(--plus-background-title); color: var(--plus-text-title); } .plus-styled :is(table.grid, table.grid td) { border: 1px solid var(--plus-primary); } /* 未发现用处 .plus-styled input[type="checkbox"]::after { background-color: #333333; } */ /* 滚动条样式 */ :is(.plus-styled, .plus-styled *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement) { scrollbar-color: var(--plus-background-2) var(--plus-background-1); } :is(.plus-styled, .plus-styled *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement):hover { scrollbar-color: var(--plus-background-3) var(--plus-background-1); } :is(.plus-styled, .plus-styled *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar { background-color: var(--plus-background-1); } :is(.plus-styled, .plus-styled *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-corner { background-color: var(--plus-background-1); } :is(.plus-styled, .plus-styled *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-thumb, .plus-styled *:not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-button { background-color: var(--plus-background-2); } :is(.plus-styled, .plus-styled *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-thumb:hover, .plus-styled *:not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-button:hover { background-color: var(--plus-background-3); } `, }, frmreview: { content: ` .plus-styled form[name="frmreview"] caption { background: var(--plus-background-title); color: var(--plus-text-title); border: 1px solid var(--plus-primary); } .plus-styled .UBB_FontSizeList li { border: 1px solid var(--plus-primary); } .plus-styled .UBB_ColorList :is(table, table td) { border: 1px solid var(--plus-primary); } .plus-styled .UBB_ColorList { background-color: var(--plus-background-1); } `, }, headfoot: { content: ` .plus-styled :is(.main.m_top, .nav, .navinner, .navlist, .nav li, :is(#left, #right, #centers, *) :is(.blocktitle, .blocktitle *)) { background: var(--plus-background-header); } .plus-styled :is(.nav a.current, .nav a:hover, .nav a:active) { background: var(--plus-background-header-active); } .plus-styled .m_foot { border-top: 1px dashed var(--plus-primary); border-bottom: 1px dashed var(--plus-primary); } `, }, indexpage: { checkers: [{ type: 'path', value: '/index.php' }, { type: 'path', value: '/' }], content: ` .plus-styled :is(:is(#left, #right, #centers) .blockcontent, .blockcontent, .odd, .even) { background-color: var(--plus-text-1); color: var(--plus-background-1); } .plus-styled :is(#left, #right, #centers, *) :is(.blocktitle, .blocktitle *) a { color: var(--plus-text-link); } a[href^="http://tieba.baidu.com"] { color: var(--plus-text-link-highlight) !important; } `, }, mousetip: { content: ` .plus-styled #tips { background-color: var(--plus-background-title); color: var(--plus-text-title); /* #f0f7ff */ border: 1px solid var(--plus-primary); } `, }, novel: { checkers: { type: 'func', value: () => { return location.pathname.startsWith('/novel/') && location.pathname.split('/').pop() !== 'index.htm'; } }, content: ` .plus-styled a { color: var(--plus-text-link-highlight); } .plus-styled #content { color: var(--plus-text-1) !important; } ` }, reviewshow: { checkers: { type: 'regurl', value: /^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/reviewshow\.php\/?/ }, content: ` .plus-styled table.grid td { background-color: var(--plus-background-1); } .plus-styled :is(#content table.grid hr, #content>table:nth-of-type(2) th, #pagelink) { border: 1px solid var(--plus-primary); } .plus-styled :is(.jieqiQuote, .jieqiCode, .jieqiNote) { background-color: var(--plus-background-2); color: var(--plus-text-2); border: 1px solid var(--plus-primary); } .plus-styled :is(.pagelink, .pagelink a:hover) { background-color: var(--plus-background-title); color: var(--plus-text-link-hover); } .plus-styled .pagelink strong { background-color: var(--plus-background-highlight); } .plus-styled .pagelink em { border-right: 1px solid var(--plus-primary); } .plus-styled .pagelink kbd { border-left: 1px solid var(--plus-primary); } .plus-styled .pagelink { border: 1px solid var(--plus-primary); } `, }, }; /** * 定义主题色的CSS(内置主题) * @type {Record<string, string>} */ const BuiltinThemes = { darkmode: ` /* 深色模式 */ body.plus-styled.plus-darkmode { /* 主要颜色 */ --plus-primary: #0d548b; /* 页面通用文字色和背景色 从底层到高层颜色逐渐加深或变浅 */ --plus-text-1: #C8C8C8; --plus-background-1: #222222; --plus-text-2: #C8C8C8; --plus-background-2: #282828; --plus-text-3: #ffffff; --plus-background-3: #383838; /* 特定用途/位置的颜色 */ --plus-text-title: #6f9ff1; --plus-text-input: #DDDDDD; --plus-background-title: #333333; --plus-background-highlight: #444444; --plus-text-hot: #f36d55; --plus-text-link: #AAAAAA; --plus-text-link-hover: #4a8dff; --plus-text-link-highlight: #4a8dff; --plus-border-disabled: #444444; --plus-background-header: #333333; --plus-background-header-active: #444444; } `, }; const Settings = CONST.Text.Styling.Settings; configs.registerConfig('styling', { GM_addValueChangeListener, items: [{ type: 'boolean', label: Settings.Enabled, caption: Settings.EnabledCaption, key: 'enabled', get() { return GM_getValue('enabled'); }, set(val) { GM_setValue('enabled', val); }, }], label: Settings.Title, listeners: { enabled(key, old_val, new_val, remote) { new_val ? install() : uninstall(); }, themes(key, old_val, new_val, remote) { // 比对新旧主题,将改动应用到页面 const old_ids = Object.keys(old_val); const new_ids = Object.keys(new_val); // 删除消失的主题 old_ids.filter(id => !new_ids.includes(id)).forEach( id => $(`plus-theme-${ id }`)?.remove()); // 添加新增的主题 new_ids.filter(id => !old_ids.includes(id)).forEach( id => addStyle(new_val[id], `plus-theme-${ id }`)); // 更新改变的主题 new_ids.filter(id => old_ids.includes(id) && old_val[id] !== new_val[id] ).forEach( id => addStyle(new_val[id], `plus-theme-${ id }`)); }, } }); GM_getValue('enabled') && install(); /** 安装本模块功能到页面 */ function install() { // 根据页面添加对应控制性css Object.entries(Styles).forEach(([id, style]) => { if (!style.checkers || FunctionLoader.testCheckers(style.checkers)) { addStyle(style.content, `plus-styling-${ id }`); } }); // 添加主题包到页面 const themes = Object.assign({}, BuiltinThemes, GM_getValue('themes')); Object.entries(themes).forEach(([id, css]) => addStyle(css, `plus-theme-${ id }`)); // body添加 plus-styled 类名 document.body.classList.add('plus-styled'); } /** 从页面卸载本模块功能 */ function uninstall() { // 移除所有控制性css Array.from($All('style[id^="plus-styling-"]')).forEach(s => s.remove()); // 移除所有主题包 Array.from($All('style[id^="plus-theme-"]')).forEach(s => s.remove()); // 移除 plus-styled 类名 document.body.classList.remove('plus-styled'); } /** * 安装一个新主题 * 这里只需要安装到存储,其他部分代码检测到存储变化会自动安装到页面的 * @param {string} id - 主题id,应全局唯一,如和已有主题id重复,则会更新该id对应主题的内容 * @param {string} css - 主题的css样式代码 */ function installTheme(id, css) { const themes = GM_getValue('themes'); themes[id] = css; GM_setValue('themes', themes); } /** * 卸载一个主题 * @param {string} id 主题的id */ function uninstallTheme(id) { const themes = GM_getValue('themes'); delete themes[id]; GM_setValue('themes', themes); } } }, unlocker: { desc: '各类网页端内容解锁', dependencies: ['api', 'utils', 'debugging'], async func() { /** @type {api} */ const api = require('api'); /** @type {utils} */ const utils = require('utils'); /** @type {debugging} */ const debugging = require('debugging'); const pool = new FunctionLoader.FuncPool(); debugging.catchPoolErrors(pool); await pool.load([ { id: 'read', desc: '在线阅读', checkers: { type: 'func', value() { const is_reader_page = ( location.pathname.startsWith('/novel/') || location.pathname.match(/\/modules\/article\/reader.php/) ) && unsafeWindow.chapter_id !== '0'; const need_unlock = $('#contentmain>:first-child')?.innerText.trim() === 'null'; return is_reader_page && need_unlock; } }, detectDom: '#footlink', async func() { Quasar.Loading.show({ message: CONST.Text.Unlocker.FetchingContent }); const content = await api.getNovelContent({ aid: utils.window.article_id, cid: utils.window.chapter_id, lang: utils.getLanguage() }); const html = content .replaceAll(/[\r\n]+/g, '<br>') .replaceAll(' ', ' ') .replaceAll( /<!--image-->([^<]+?)<!--image-->/g, `<div class="divimage"><a href="$1" target="_blank"><img src="$1" border="0" class="imagecontent"></a></div>` ); [...$('#content').childNodes].forEach(elm => elm.remove()); $('#content').insertAdjacentHTML('afterbegin', html); Quasar.Loading.hide(); } }, { id: 'download', desc: '下载', checkers: [{ type: 'regpath', value: /\/book\/\d+\.htm/ }, { type: 'path', value: '/modules/article/articleinfo.php' }, { type: 'path', value: '/modules/article/packshow.php' }], async func() { const pool = new FunctionLoader.FuncPool(); debugging.catchPoolErrors(pool); await pool.load([ { id: 'bookinfo', desc: '书籍介绍页', checkers: [{ type: 'regpath', value: /\/book\/\d+\.htm/ }, { type: 'startpath', value: '/modules/article/articleinfo.php' }], detectDom: '.main.m_foot', async func() { // 检查是否需要解锁 if ($('#content>div:first-child fieldset>legend>b')) { return; } // 需要解锁,创建下载页面入口 const aid = new URLSearchParams(location.search).get('id') ?? location.href.match(/book\/(\d+)\.htm/)[1]; const bookinfo = await api.getNovelShortInfo({ aid, lang: utils.getLanguage() }); const title = bookinfo.querySelector('[name="Title"]').firstChild.nodeValue; const div = $$CrE({ tagName: 'div', attrs: { style: 'margin:0px auto;overflow:hidden;' } }); div.innerHTML = ` <fieldset style="width:820px;height:35px;margin:0px auto;padding:0px;"> <legend><b>《${title}》小说TXT、UMD、JAR电子书下载</b></legend> <div style="width:210px; float:left; text-align:center;"><a href="https://${ location.host }/modules/article/packshow.php?id=${aid}&type=txt">TXT简繁分卷</a></div> <div style="width:210px; float:left; text-align:center;"><a href="https://${ location.host }/modules/article/packshow.php?id=${aid}&type=txtfull">TXT简繁全本</a></div> <div style="width:210px; float:left; text-align:center;"><a href="https://${ location.host }/modules/article/packshow.php?id=${aid}&type=umd">UMD全本下载</a></div> <div style="width:190px; float:left; text-align:center;"><a href="https://${ location.host }/modules/article/packshow.php?id=${aid}&type=jar">JAR全本下载</a></div> </fieldset> `; $('#content>div:first-child').insertAdjacentElement('beforeend', div); } }, { id: 'download', desc: '下载页', checkers: { type: 'startpath', value: '/modules/article/packshow.php' }, async func() { /* 页面加载思路: 1. 在锁定的页面引入iframe,导航至文学少女的对应packshow页面 2. iframe内运行的脚本实例负责将此页面修改为对应书籍的页面 因此需要加载两个不同oFunc: 1. 检测到锁定页面内容,引入iframe 2. 检测到外部为锁定页面的文学少女iframe,修改页面内容 */ const pool = new FunctionLoader.FuncPool(); debugging.catchPoolErrors(pool); await pool.load([ { id: 'outer', desc: '外部锁定页面', checkers: { type: 'switch', value: isLockedPage(utils.window) }, detectDom: '.blocknote, .main.m_foot', func() { Quasar.Loading.show({ message: CONST.Text.Unlocker.ConstructingPage }); const search = new URLSearchParams(location.search); const url = new URL(location.href); search.set('id', CONST.Internal.UnlockTemplateAID.toString()); url.search = search.toString(); const iframe = $$CrE({ tagName: 'iframe', props: { src: url.href }, styles: { position: 'fixed', top: '0', left: '0', width: '100vw', height: '100vh', border: '0', padding: '0', margin: '0', background: 'white', zIndex: '-1', opacity: '0.001', }, listeners: [['load', e => { Quasar.Loading.hide(); iframe.style.zIndex = '1'; iframe.style.opacity = '1'; document.body.style.overflow = 'hidden'; }]] }); document.body.append(iframe); } }, { id: 'inner', desc: '内部《文学少女》页面', checkers: { type: 'func', value() { const in_iframe = utils.window.top !== utils.window; const id_corrent = new URLSearchParams(location.search).get('id') === CONST.Internal.UnlockTemplateAID.toString(); const outer_locked = isLockedPage(utils.window.top); return in_iframe && id_corrent && outer_locked; }, }, async func() { Quasar.Loading.show({ message: CONST.Text.Unlocker.FetchingDownloadInfo }); // 获取书籍信息 const aid = new URLSearchParams(utils.window.top.location.search).get('id'); const lang = utils.getLanguage(); const [templateinfo, bookinfo, bookindex] = await Promise.all([ api.getNovelFullMeta({ aid: CONST.Internal.UnlockTemplateAID, lang }), api.getNovelFullMeta({ aid, lang }), api.getNovelIndex({ aid, lang }) ]); const template_title = templateinfo.querySelector('[name="Title"]').firstChild.nodeValue; const book_title = $(bookinfo, '[name="Title"]').firstChild.nodeValue; const book_update = $(bookinfo, '[name="LastUpdate"]').getAttribute('value'); const book_length = $(bookinfo, '[name="BookLength"]').getAttribute('value'); // 处理页面内导航 $AEL(document, 'click', function(event) { const anchor = event.target.closest('a'); if (anchor && anchor.href && !anchor.target) { if (!event.ctrlKey && !event.metaKey && !event.shiftKey) { event.preventDefault(); window.top.location.href = anchor.href; } } }, true); // 页面标题 utils.window.top.document.title = document.title.replaceAll(template_title, book_title); // 所有指向《文学少女》的链接改为指向目标书籍 detectDom({ selector: 'a', callback(a) { const template_pathname = `/book/${CONST.Internal.UnlockTemplateAID}.htm`; if (a.pathname === template_pathname) { a.pathname = `/book/${aid}.htm`; } } }); // 下载表格表头标题书名改为目标书籍书名 (await detectDom('#content>table>caption>a')).innerText = book_title; // 重建下载列表 [...$All('#content>table tr:not(:first-of-type)')].forEach(tr => tr.remove()); const tbody = $('#content>table>tbody'); const type = new URLSearchParams(location.search).get('type'); const list_builders = { async txt() { for (const volume of $All(bookindex, 'volume')) { const volume_title = volume.firstChild.nodeValue; const vid = volume.getAttribute('vid'); const tr = $CrE('tr'); tr.innerHTML = ` <td class="odd">${ volume_title }</td> <td class="even" align="center"> <a href="https://dl.wenku8.com/packtxt.php?aid=${ aid }&vid=${ vid }&charset=gbk" target="_blank">简体(G)</a> <a href="https://dl.wenku8.com/packtxt.php?aid=${ aid }&vid=${ vid }&charset=utf-8" target="_blank">简体(U)</a> <a href="https://dl.wenku8.com/packtxt.php?aid=${ aid }&vid=${ vid }&charset=big5" target="_blank">繁体(U)</a> </td> <td class="even" align="center"> <a href="https://dl.wenku8.com/packtxt.php?aid=${ aid }&vid=${ vid }&aname=${ $URL.encode(book_title) }&vname=${ $URL.encode(volume_title) }&charset=gbk" target="_blank">简体(G)</a> <a href="https://dl.wenku8.com/packtxt.php?aid=${ aid }&vid=${ vid }&aname=${ $URL.encode(book_title) }&vname=${ $URL.encode(volume_title) }&charset=utf-8" target="_blank">简体(U)</a> <a href="https://dl.wenku8.com/packtxt.php?aid=${ aid }&vid=${ vid }&aname=${ $URL.encode(book_title) }&vname=${ $URL.encode(volume_title) }&charset=big5" target="_blank">繁体(U)</a> </td> `; tbody.append(tr); } }, async txtfull() { const tr = $CrE('tr'); tr.innerHTML = ` <td class="odd" align="center">${ book_update }</td> <td class="even" align="center">${ Math.round(book_length * 2 / 1024) }K(G版) / ${ Math.round(book_length * 3 / 1024) }K(U版)</td> <td class="even" align="center"> 简体(G)(<a href="https://dl.wenku8.com/down.php?type=txt&node=1&id=${ aid }" target="_blank">载点一</a> <a href="https://dl.wenku8.com/down.php?type=txt&node=2&id=${ aid }" target="_blank">载点二</a>) 简体(U)(<a href="https://dl.wenku8.com/down.php?type=utf8&node=1&id=${ aid }" target="_blank">载点一</a> <a href="https://dl.wenku8.com/down.php?type=utf8&node=2&id=${ aid }" target="_blank">载点二</a>) 繁体(U)(<a href="https://dl.wenku8.com/down.php?type=big5&node=1&id=${ aid }" target="_blank">载点一</a> <a href="https://dl.wenku8.com/down.php?type=big5&node=2&id=${ aid }" target="_blank">载点二</a>) </td> `; tbody.append(tr); }, async umd() { const tr = $CrE('tr'); tr.innerHTML = ` <td class="odd" align="center">全本</td> <td class="even" align="center">未知</td> <td class="odd" align="center">${ book_update }</td> <td class="odd">${ $(bookindex, 'volume:first-of-type').firstChild.nodeValue } - ${ $(bookindex, 'volume:last-of-type').firstChild.nodeValue }</td> <td class="even" align="center"><a href="https://dl.wenku8.com/down.php?type=umd&id=${ aid }&vsize=0&vid=1" target="_blank">下载UMD</a> </td> `; tbody.append(tr); }, async jar() { const tr = $CrE('tr'); tr.innerHTML = ` <td class="odd" align="center">全本</td> <td class="even" align="center">未知</td> <td class="odd" align="center">${ book_update }</td> <td class="odd">${ $(bookindex, 'volume:first-of-type').firstChild.nodeValue } - ${ $(bookindex, 'volume:last-of-type').firstChild.nodeValue }</td> <td class="even" align="center"><a href="https://dl.wenku8.com/down.php?type=jar&id=${ aid }&vsize=0&vid=1" target="_blank">下载JAR</a> <a href="https://dl.wenku8.com/down.php?type=jad&id=${ aid }&vsize=0&vid=1" target="_blank">下载JAD</a></td> `; tbody.append(tr); }, }; await list_builders[type](); Quasar.Loading.hide(); } } ]); /** * 判断给定页面是否为锁定的下载页面 * @param {Window} win * @returns {boolean} */ function isLockedPage(win) { const path_correct = win.location.pathname.startsWith('/modules/article/packshow.php'); const messages = [ '错误原因:对不起,该文章不存在!', '錯誤原因︰對不起,該文章不存在!' ] const content_correct = messages.some(message => win.document.body.innerText.includes(message)); return path_correct && content_correct; } } } ]); } } ]); } }, darkmode: { desc: '深色模式', css: [ // Common { id: 'common', checker: { type: 'switch', value: true, }, css: 'body.plus-darkmode:not(#stonger-than-quasar) {background-color: #222222;color: #C8C8C8;}' }, // Mouse tip { id: 'mousetip', checker: { type: 'regurl', value: /^https?:\/\/www\.wenku8\.(net|cc)\// }, css: '.plus-darkmode #tips {background-color: #333333;color: #f0f7ff;border: 1px solid #0d548b;}' }, // .block { id: 'block', checker: { type: 'regurl', value: /^https?:\/\/www\.wenku8\.(net|cc)\// }, css: '.plus-darkmode :is(#left,#right,*) .blockcontent{background-color:#222222}.plus-darkmode :is(#left,#right,*) .blocknote{background-color:#282828}.plus-darkmode :is(#left,#right,#centers,*) :is(.blocktitle,.blocktitle *,.ultop li){color:#6f9ff1}.plus-darkmode :is(#left,#right,#centers,*) .blocktitle>:is(.txt,.txtr){background-color:#383838;line-height:27px;padding-top:0}.plus-darkmode .block{border:1px solid #0d548b}.plus-darkmode .blocktitle{border-color:#333333}.plus-darkmode :is(.blockcontent,.blocknote){border-color:#0d548b}.plus-darkmode .block :is(.ultop li,.ultops li){border-bottom:1px dashed #0d548b}' }, // header and footer { id: 'headfoot', checker: { type: 'regurl', value: /^https?:\/\/www\.wenku8\.(net|cc)\// }, css: '.plus-darkmode :is(.main.m_top, .nav, .navinner, .navlist, .nav li, :is(#left, #right, #centers, *) :is(.blocktitle, .blocktitle *)) {background: #333333;}.plus-darkmode :is(.nav a.current, .nav a:hover, .nav a:active) {background: #444444;}.plus-darkmode .m_foot {border-top: 1px dashed #0d548b;border-bottom: 1px dashed #0d548b;}' }, // elements (input textarea .button scrollbar, etc) { id: 'element', checker: { type: 'regurl', value: /^https?:\/\/www\.wenku8\.(net|cc)\// }, css: '.plus-darkmode :is(.even, .odd) {background-color: #222222;}.plus-darkmode table.grid td {background-color: #222222 !important;}.plus-darkmode :is(input:not([type]:not([type="text"], [type="number"], [type="file"], [type="password"])), textarea, .plus_list_item, button):not([class*="q-"]:not(body) *) {background-color: #333333;color: #DDDDDD;}.plus-darkmode :is(.button, input[type="button"]) {color: #C8C8C8;background-color: #333333;}.plus-darkmode select {color: #AAAAAA;background-color: #333333;}.plus-darkmode :is(.hottext, a.hottext) {color: #f36d55;}.plus-darkmode :is(.button, select, textarea, input:not(.plus_list_item>input, .UBB_ColorList input), .plus_list_item):not(:disabled, [class*="q-"]:not(body) *) {border: 1px solid #0d548b;}.plus-darkmode :is(input, textarea, button):disabled {border: 2px solid #444444;}.plus-darkmode a {color: #AAAAAA;}.plus-darkmode a:hover {color: #4a8dff;}.plus-darkmode a:is(.ultop li a, .poptext, a.poptext, .ultops li a) {color: #f36d55;}.plus-darkmode :is(table.grid caption, .gridtop, table.grid th, .head) {border: 1px solid #0d548b;background: #333333;color: #6f9ff1;}.plus-darkmode :is(table.grid, table.grid td) {border: 1px solid #0d548b;}.plus-darkmode input[type="checkbox"]::after {background-color: #333333;}.plus-darkmode :is(.pagelink, .pagelink a:hover) {background-color: #333333;color: #6f9ff1;}.plus-darkmode .pagelink strong {background-color: #444444;}.plus-darkmode .pagelink em {border-right: 1px solid #0d548b;}.plus-darkmode .pagelink kbd {border-left: 1px solid #0d548b;}.plus-darkmode .pagelink {border: 1px solid #0d548b;}:is(.plus-darkmode, .plus-darkmode *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement) {scrollbar-color: #444444 #333333;}:is(.plus-darkmode, .plus-darkmode *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement):hover {scrollbar-color: #484848 #333333;}:is(.plus-darkmode, .plus-darkmode *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar {background-color: #333333;}:is(.plus-darkmode, .plus-darkmode *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-corner {background-color: #333333;}:is(.plus-darkmode, .plus-darkmode *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-thumb, .plus-darkmode *:not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-button {background-color: #444444;}:is(.plus-darkmode, .plus-darkmode *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-thumb:hover, .plus-darkmode *:not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-button:hover {background-color: #484848;}' }, // dialog { id: 'dialog', checker: { type: 'regurl', value: /^https?:\/\/www\.wenku8\.(net|cc)\// }, css: '.plus-darkmode #dialog {color: #C8C8C8;background-color: #222222;border: 5px solid #0d548b;}.plus-darkmode #dialog a[onclick="closeDialog()"] {border: 1px solid #0d548b !important;outline: thin solid #0d548b !important;}' }, // replyarea { id: 'replyarea', checker: [ // Page: reviews list '/modules/article/reviews.php', // Page: review '/modules/article/reviewshow.php', // Page: review edit '/modules/article/reviewedit.php', // Page: book '/book/', '/modules/article/articleinfo.php', ].map(p => ({ type: 'startpath', value: p })), css: '.plus-darkmode form[name="frmreview"] caption {background: #333333;color: #6f9ff1;border: 1px solid #0d548b;}' }, // index page { id: 'index', checker: [{ type: 'path', value: '/index.php' }, { type: 'path', value: '/' }], css: '.plus-darkmode :is(:is(#left, #right, #centers) .blockcontent, .blockcontent, .odd, .even) {background-color: #222222;color: #C8C8C8;}.plus-darkmode :is(#left, #right, #centers, *) :is(.blocktitle, .blocktitle *) a {color: #AAAAAA;}a[href^="http://tieba.baidu.com"] {color: #4a8dff !important;}', }, // login page { id: 'login', checker: { type: 'path', value: '/login.php' }, css: '' }, // Book { id: 'book', checker: [{ type: 'regpath', value: /\/book\/\d+\.htm/ }, { type: 'regpath', value: /\/modules\/article\/articleinfo\.php/ }], css: '.plus-darkmode :is(:is(#left, #right, #centers) .blockcontent, .blockcontent, .odd, .even) {background-color: #222222;color: #C8C8C8;}.plus-darkmode table.grid td {background-color: #222222 !important;}.plus-darkmode table.grid:not(form table) tr:first-of-type>td:nth-of-type(2n+1) {background-color: #333333 !important;}.plus-darkmode table.grid:not(form table, #content .main>table:first-of-type) tr:first-of-type>td:first-of-type {color: #6f9ff1;}.plus-darkmode fieldset {border: 2px solid #0d548b;}.plus-darkmode :is(table.grid, table.grid td, table.grid caption, .gridtop) {border: 1px solid #0d548b;}' }, // Book index { id: 'bookindex', checker: [{ type: 'regpath', value: /^\/novel\/\d+\/\d+\/index\.html?$/ }, { type: 'path', value: '/modules/article/reader.php' }], css: '.plus-darkmode :is(.css, .vcss, .ccss) {background-color: #222222;color: #C8C8C8;}.plus-darkmode #headlink {border-bottom: 1px solid #0d548b;border-top: 1px solid #0d548b;}.plus-darkmode :is(.css, .vcss, .ccss) {border: 1px solid #0d548b;border-collapse: collapse;}' }, // Novel { id: 'novel', checker: { type: 'func', value: () => { return location.pathname.startsWith('/novel/') && location.pathname.split('/').pop() !== 'index.htm'; } }, css: '.plus-darkmode a {color: #4a8dff;} .plus-darkmode #content {color: rgb(200, 200, 200) !important;}' }, // Reviewshow { id: 'reviewshow', checker: { type: 'regurl', value: /^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/reviewshow\.php\/?/ }, css: '.plus-darkmode table.grid td {background-color: #222222;}.plus-darkmode :is(#content table.grid hr, #content>table:nth-of-type(2) th, #pagelink) {border: 1px solid #0d548b;}.plus-darkmode :is(.jieqiQuote, .jieqiCode, .jieqiNote) {background-color: #282828;color: #6f9ff1;border: 1px solid #0d548b;}' }, // frmreview { id: 'frmreview', checker: [ // Page: reviews list '/modules/article/reviews.php', // Page: review '/modules/article/reviewshow.php', // Page: review edit '/modules/article/reviewedit.php', // Page: book '/book/', '/modules/article/articleinfo.php', ].map(p => ({ type: 'startpath', value: p })), css: '.plus-darkmode .UBB_FontSizeList li {border: 1px solid #0d548b;}.plus-darkmode .UBB_ColorList :is(table, table td) {border: 1px solid #0d548b;}.plus-darkmode .UBB_ColorList {background-color: #222222;}' }, /* Template { id: '', checker: { type: 'regurl', value: /^https?:\/\/www\.wenku8\.(net|cc)\// }, css: '' }, */ ], dependencies: ['dependencies', 'utils', 'debugging', 'configs'], params: ['oFunc', 'GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], /** @typedef {Awaited<ReturnType<typeof functions.darkmode.func>>} darkmode */ async func(oFunc, GM_setValue, GM_getValue, GM_addValueChangeListener) { /** @type {configs} */ const configs = require('configs'); /** @type {utils} */ const utils = require('utils'); /** @type {debugging} */ const debugging = require('debugging'); // 带默认值的GM_getValue GM_getValue = utils.defaultedGet({ enabled: false, follow_system: false, sidebutton: true, }, GM_getValue); // 设置项、配置存储管理 与 基于设置项的功能切换 configs.registerConfig('darkmode', { GM_addValueChangeListener, label: CONST.Text.Darkmode.Settings.Label, items: [{ type: 'boolean', label: CONST.Text.Darkmode.Settings.Enbaled, caption: CONST.Text.Darkmode.Settings.EnabledCaption, key: 'enabled', get() { return isEnabled(); }, set(val) { setEnabled(val); }, }, { type: 'boolean', label: CONST.Text.Darkmode.Settings.FollowSystem, caption: CONST.Text.Darkmode.Settings.FollowSystemCaption, key: 'follow_system', get() { return isFollow(); }, set(val) { setFollow(val); } }, { type: 'boolean', label: CONST.Text.Darkmode.Settings.SideButton, caption: CONST.Text.Darkmode.Settings.SideButtonCaption, key: 'sidebutton', get() { return GM_getValue('sidebutton'); }, set(val) { return GM_setValue('sidebutton', val); }, }], listeners: { enabled: applyDarkmode, follow_system: applyDarkmode, sidebutton(key, old_val, val, remote) { updateSideButton(val); } }, }); // 应用合适的样式表到页面 oFunc.css.forEach(css => { FunctionLoader.testCheckers(css.checker) && addStyle(css.css, `darkmode-${css.id}`); }); // 深色模式切换回调列表 /** @type {((enabled: boolean) => any)[]} */ const listeners = []; // 根据配置切换深色模式 applyDarkmode(); // 每当系统深色模式切换时,重新根据配置切换深色模式 const darkmode_mediaquery = window.matchMedia('(prefers-color-scheme: dark)'); $AEL(darkmode_mediaquery, 'change', e => applyDarkmode()); // 侧边栏添加深色模式开关 require('sidepanel', true).then(() => updateSideButton()); /** * 检查深色模式是否开启 * @returns {boolean} */ function isEnabled() { return GM_getValue('enabled'); } /** * 设置深色模式开启状态,并应用到页面 * @param {boolean} enabled - 深色模式是否开启 */ function setEnabled(enabled) { GM_setValue('enabled', enabled); } /** * 检查深色模式是否跟随系统 * @returns {boolean} */ function isFollow() { return GM_getValue('follow_system'); } /** * 设置深色模式跟随系统,并应用到页面 * @param {boolean} follow - 深色模式是否跟随系统 */ function setFollow(follow) { GM_setValue('follow_system', follow); } /** * 根据设置综合计算是否应用深色模式,并应用更改到页面;当实际发生更改时,回调listeners */ function applyDarkmode() { const enabled = isActualDark(); const cur_enabled = document.body.classList.contains('plus-darkmode'); if (cur_enabled !== enabled) { document.body.classList[enabled ? 'add' : 'remove']('plus-darkmode'); require('dependencies', true).then(() => Quasar.Dark.set(enabled)); listeners.forEach(listener => debugging.callWithErrorHandling(listener, null, [enabled])); } } /** * 获取各种设置综合效果下的**实际深色模式启用状态** * @returns {boolean} */ function isActualDark() { return isFollow() ? getSystemDarkmode() : isEnabled(); } /** * 检测系统深色模式是否开启 * @returns {boolean} */ function getSystemDarkmode() { return window.matchMedia('(prefers-color-scheme: dark)').matches; } /** * 切换是否展示深色模式开关按钮 * @param {boolean} [show_button] - 是否展示开关按钮,不提供时使用存储的配置 */ async function updateSideButton(show_button) { /** @type {sidepanel} */ const sidepanel = await require('sidepanel', true); show_button = show_button ?? GM_getValue('sidebutton'); if (show_button) { sidepanel.hasButton('darkmode.toggle') || sidepanel.registerButton({ id: 'darkmode.toggle', icon: isEnabled() ? 'light_mode' : 'dark_mode', label: isEnabled() ? CONST.Text.Darkmode.Switch2Light : CONST.Text.Darkmode.Switch2Dark, index: 1, callback() { const enabled = !isEnabled(); sidepanel.updateButton('darkmode.toggle', { icon: enabled ? 'light_mode' : 'dark_mode', label: enabled ? CONST.Text.Darkmode.Switch2Light : CONST.Text.Darkmode.Switch2Dark }); setEnabled(enabled); if (isFollow()) { Quasar.Notify.create({ type: 'warning', message: CONST.Text.Darkmode.FollowEnabledTip, caption: CONST.Text.Darkmode.FollowEnabledTipCaption, group: 'darkmode.darkmode-tip', }); } } }); } else { sidepanel.hasButton('darkmode.toggle') && sidepanel.removeButton('darkmode.toggle'); } } /** * 注册(不可用)当页面实际深色/浅色模式进行切换时的回调 * @param {(enabled: boolean) => any} callback - 页面实际深色/浅色模式切换的回调,参数为深色模式是否开启 */ function onToggle(callback) { listeners.push(callback); } /** * 根据url,筛选出属于此页面的css样式列表 * @param {string} url - 页面url * @returns {string[]} - 全部样式css的数组 */ function getPageCSS(url) { return oFunc.css.filter(css => FunctionLoader.testCheckers(css.checker)).map(css => css.css); } /** * 获取指定的css样式字符串 * @param {string} id - css样式的id */ function getCSS(id) { return oFunc.css.find(css => css.id === id).css; } /** * 将指定的css作为<style>元素添加到指定的父元素中 * @param {string} id - css样式的id * @param {HTMLElement} parent - 父元素 */ function applyCSS(id, parent) { addStyle(parent, getCSS(id)); } return { get enabled() { return isEnabled(); }, set enabled(val) { setEnabled(val); }, get follow_system() { return isFollow(); }, set follow_system(val) { setFollow(val); }, get actual_enabled() { return isActualDark(); }, onToggle, getPageCSS, getCSS, applyCSS, }; } }, review: { desc: '书评页面增强', checkers: { type: 'path', value: '/modules/article/reviewshow.php' }, dependencies: ['dependencies', 'debugging', 'utils', 'configs'], params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], /** @typedef {Awaited<ReturnType<typeof functions.review.func>>} review */ async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { /** @type {utils} */ const utils = require('utils'); /** @type {configs} */ const configs = require('configs'); // 如果是发评论返回的提示页面,不继续运行 if ($All('.block').length === 1) { return; } // 注册(不可用)设置组 configs.registerConfig('review', { GM_addValueChangeListener, label: CONST.Text.Review.Settings.Label }); /* 通信信使,通过CustomEvent传递消息,目前有以下事件: - update 代表当前页面内容被更新,有楼层被更新,或有新楼层加入页面 - floors 被更新或者新增的楼层实例 */ const messager = new EventTarget(); /** * 书评页面每条评论称为一个楼层,即Floor * Floor类型表示一个楼层,一条评论 * @typedef {Object} Floor * @property {FloorElement} element * @property {FloorData} data */ /** * {@link Floor} 类型中的页面元素 * @typedef {Object} FloorElement * @property {HTMLTableElement} root - table根元素 * @property {HTMLTableCellElement} userarea - 左侧用户区 * @property {HTMLImageElement} avatar - 用户头像图片元素 * @property {HTMLAnchorElement} userlink - 用户名链接 * @property {FloorUserLine[]} userlines - 用户区域中的用户信息行结合 * @property {FloorButton[]} userbuttons - 用户相关操作按钮集合 * @property {HTMLTableCellElement} contentarea - 右侧内容区 * @property {HTMLElement} title - 标题strong元素 * @property {FloorButton[]} floorbuttons - 楼层相关操作按钮集合 * @property {HTMLDivElement} metaarea - 楼层相关操作按钮,以及楼层时间所在容器 * @property {HTMLDivElement} content - 内容正文区 */ /** * {@link FloorElement} 类型中的操作按钮 * @typedef {Object} FloorButton * @property {string} id - 按钮ID,全局唯一 * @property {boolean} wenku - 是否为文库自带按钮 * @property {number} index - 按钮排序位置,文库自带按钮均为负数,新添加按钮均为正数,升序排列 * @property {HTMLElement} element - 按钮DOM元素 */ /** * {@link FloorElement} 类型中的用户信息行 * @typedef {Object} FloorUserLine * @property {string} id - 行ID,全局唯一 * @property {boolean} wenku - 是否为文库自带行 * @property {HTMLElement | Text} element - 行DOM节点 */ /** * {@link Floor} 类型中的楼层数据 * @typedef {Object} FloorData * @property {FloorUser} user - 层主用户数据 * @property {string} title - 楼层标题 * @property {string} content - 楼层内容 * @property {number} time - 楼层时间戳 * @property {string} url - 楼层链接 * @property {number} rid - 书评id * @property {number} yid - 楼层id * @property {number} number - 楼层编号 * @property {boolean} highlight - 是否对楼层应用了高亮效果 */ /** * {@link FloorData} 类型中的用户数据 * @typedef {Object} FloorUser * @property {string} avatar - 用户头像src * @property {string} name - 用户名 * @property {number} id - 用户数字id * @property {FloorUserType} type - 用户类型 * @property {FloorUserLevel} level - 用户等级 * @property {number} jointime - 加入日期时间戳 * @property {number} experience - 经验 * @property {number} credit - 积分 */ /** * 用户类型 * @typedef {'admin' | 'user' | 'banned' | 'limited'} FloorUserType */ /** * 用户等级 * @typedef {'newbie' | 'normal' | 'intermediate' | 'advanced' | 'golden' | 'elder'} FloorUserLevel */ const pool_funcs = { FloorManager: { desc: '楼层内容解析器', /** @typedef {Awaited<ReturnType<typeof pool_funcs.FloorManager.func>>} FloorManager */ async func() { const pool_funcs = { parser: { desc: '楼层内容解析器', /** @typedef {Awaited<ReturnType<typeof pool_funcs.parser.func>>} parser */ func() { /** * 从给定文档中解析所有Floor * @param {Document} [doc=document] - 需解析的Document文档,默认为window.document,也可以是任何其他文档,如从xhr请求获取的文档 * @returns {Floor[]} */ function parseAll(doc = document) { return [...$All(doc, '#content > table.grid')].filter(table => $(table, 'img.avatar')).map(table => parse(table)); } /** * 将楼层DOM结构解析为标准楼层对象 * 仅可解析未经修改的Wenku自带DOM * @param {HTMLTableElement} table - 楼层的table根元素 * @returns {Floor} */ function parse(table) { const element = parseElement(table); const data = parseData(element); return { element, data }; } /** * 从楼层DOM结构解析标准楼层元素对象 * 仅可解析未经修改的Wenku自带DOM * @param {HTMLTableElement} table - 楼层的table根元素 * @returns {FloorElement} */ function parseElement(table) { const userarea = $(table, 'td:first-of-type'); const contentarea = $(table, 'td:last-of-type'); const avatar = $(userarea, 'img.avatar'); const userlink = $(userarea, 'strong>a'); const userlines = getUserLines(); const userbuttons = [{ id: 'message', wenku: true, index: -2, element: $(userarea, 'a[onclick^="openDialog(\'/newmessage.php?"]') }, { id: 'detail', wenku: true, index: -1, element: $(userarea, `a[href^="https://${ location.host }/userpage.php?"]:not(strong > a)`), }]; const title = $(table, 'td:last-of-type > div:nth-of-type(1) > strong'); const floorbuttons = getFloorButtons(); const metaarea = $(table, 'td:last-of-type > div:nth-of-type(2)'); const content = $(table, 'td:last-of-type > div:nth-of-type(3)'); return { root: table, userarea, contentarea, avatar, userlink, userbuttons, userlines, title, floorbuttons, metaarea, content, } function getUserLines() { // 获取userlink后的第index个textnode的方法,index从1开始 const getTextNode = index => { let elm = userlink.parentElement; for (let i = 0; i < index; i++) { elm = elm.nextElementSibling; } return elm.nextSibling; } const getText = index => getTextNode(index).nodeValue.trim(); /** @type {FloorUserLine[]} */ const lines = [{ id: 'type', wenku: true, element: getTextNode(1) }, { id: 'level', wenku: true, element: getTextNode(2) }, { id: 'jointime', wenku: true, element: getTextNode(3) }, { id: 'experience', wenku: true, element: getTextNode(4) }, { id: 'credit', wenku: true, element: getTextNode(5) }]; return lines; } function getFloorButtons() { const floorbuttons = [{ id: 'link', wenku: true, element: $(table, 'td:last-of-type > div:nth-of-type(2) > a[href^="#yid"]'), }]; const edit = $(table, 'td:last-of-type > div:nth-of-type(2) > a[href*="/modules/article/reviewedit.php?yid="]'); edit && floorbuttons.push({ id: 'edit', wenku: true, index: -1, element: edit, }); return floorbuttons; } } /** * 从楼层元素对象解析楼层数据 * 仅可解析未经修改的Wenku自带DOM * @param {FloorElement} element - 楼层元素对象 * @returns {FloorData} */ function parseData(element) { const getLineText = line_id => element.userlines.find(l => l.id === line_id).element.nodeValue.trim(); /** @type {FloorUser} */ const user = { avatar: element.avatar.src, name: element.userlink.innerText, id: new URL(element.userlink.href).searchParams.get('uid'), type: utils.getUserType(getLineText('type')), level: utils.getUserLevel(getLineText('level')), jointime: new Date(getLineText('jointime').match(/\d+-\d+-\d+/)[0]).getTime(), experience: parseInt(getLineText('experience').match(/\d+/)[0], 10), credit: parseInt(getLineText('credit').match(/\d+/)[0], 10), }; const title = element.title.innerText; const content = parseContent(element.content); const link_elm = element.floorbuttons.find(b => b.id === 'link').element; const last_floor_button = element.floorbuttons[element.floorbuttons.length-1].element; const time = new Date(last_floor_button.previousSibling.nodeValue.match(/\d+-\d+-\d+ +\d+:\d+:\d+/)[0]).getTime(); const url = link_elm.href; const rid = parseInt(new URLSearchParams(link_elm.search).get('rid'), 10); const yid = parseInt(link_elm.hash.match(/\d+/)[0], 10); const number = parseInt(link_elm.innerText.match(/\d+/)[0], 10); const highlight = false; return { user, title, content, time, url, rid, yid, number, highlight }; } // Get floor content by BBCode format (content only, no title) // Argv: <div> content element /** * 从正文内容div的DOM结构中解析bbcode源代码 * @param {HTMLDivElement} content_elm * @param {boolean} [use_img_tag=false] * @returns {string} */ function parseContent(content_elm, use_img_tag=false) { const subNodes = content_elm.childNodes; let content = ''; for (const node of subNodes) { const type = node.nodeName; switch (type) { case '#text': // Prevent 'Quote:' repeat content += node.data.replace(/^\s*Quote:\s*$/, ' '); break; case 'IMG': // wenku8 has forbidden [img] tag for secure reason (preventing CSRF) //content += '[img]S[/img]'.replace('S', node.src); content += use_img_tag ? '[img]S[/img]'.replace('S', node.src) : ' S '.replace('S', node.src); break; case 'A': content += '[url=U]T[/url]'.replace('U', node.getAttribute('href')).replace('T', parseContent(node)); break; case 'BR': // no need to add \n, because \n will be preserved in #text nodes //content += '\n'; break; case 'DIV': if (node.classList.contains('jieqiQuote')) { content += getTagedSubcontent('quote', node); } else if (node.classList.contains('jieqiCode')) { content += getTagedSubcontent('code', node); } else if (node.classList.contains('divimage')) { content += parseContent(node, use_img_tag); } else { content += parseContent(node, use_img_tag); } break; case 'CODE': content += parseContent(node, use_img_tag); break; // Just ignore case 'PRE': content += parseContent(node, use_img_tag); break; // Just ignore case 'SPAN': content += getFontedSubcontent(node); break; // Size and color case 'P': content += getFontedSubcontent(node); break; // Text Align case 'B': content += getTagedSubcontent('b', node); break; case 'I': content += getTagedSubcontent('i', node); break; case 'U': content += getTagedSubcontent('u', node); break; case 'DEL': content += getTagedSubcontent('d', node); break; default: content += parseContent(node, use_img_tag); break; } } return content; function getTagedSubcontent(tag, node) { const subContent = parseContent(node, use_img_tag); return '[{T}]{S}[/{T}]'.replaceAll('{T}', tag).replaceAll('{S}', subContent); } function getFontedSubcontent(node) { let tag, value; let strSize = node.style.fontSize.match(/\d+/); let strColor = node.style.color; let strAlign = node.align; strSize = strSize ? strSize[0] : null; strColor = strColor ? rgbToHex.apply(null, strColor.match(/\d+/g)) : null; tag = tag || (strSize ? 'size' : null); tag = tag || (strColor ? 'color' : null); tag = tag || (strAlign ? 'align' : null); value = value || strSize || null; value = value || strColor || null; value = value || strAlign || null; const subContent = parseContent(node, use_img_tag); if (tag && value) { return '[{T}={V}]{S}[/{T}]'.replaceAll('{T}', tag).replaceAll('{V}', value).replaceAll('{S}', subContent); } else { return subContent; } function rgbToHex(r, g, b) {return ((r << 16) | (g << 8) | b).toString(16).padStart('0', 6);} } } /** * 根据id获取一个楼层操作按钮 * @param {Floor} floor * @param {string} id * @returns {FloorButton | null} */ function getFloorButton(floor, id) { return floor.element.floorbuttons.find(b => b.id === id); } /** * 根据id获取一个用户操作按钮 * @param {Floor} floor * @param {string} id * @returns {FloorButton | null} */ function getUserButton(floor, id) { return floor.element.userbuttons.find(b => b.id === id); } /** * 根据id获取一个用户信息行 * @param {Floor} floor * @param {string} id * @returns {FloorUserLine | null} */ function getUserLine(floor, id) { return floor.element.userlines.find(l => l.id === id); } return { parse, parseAll, parseContent, getFloorButton, getUserButton, getUserLine, } } }, transformer: { desc: '楼层内容修改器', dependencies: 'parser', /** @typedef {Awaited<ReturnType<typeof pool_funcs.transformer.func>>} transformer */ func() { /** @type {parser} */ const parser = pool.require('parser'); /** * 在楼层右上角按钮处新增一个按钮 * @param {Floor} floor * @param {Object} options * @param {string} options.id * @param {string} options.label * @param {number} options.index - 按钮排序位置,仅在非文库自带按钮间排序,文库按钮均在非文库按钮之前 * @param {function} [options.callback] - 按钮点击回调,和element二选一 * @param {function} [options.element] - 按钮元素,和callback二选一 * @returns {FloorButton} */ function addFloorButton(floor, { id, label, index, callback=null, element=null }) { const floorbuttons = floor.element.floorbuttons; // 创建按钮元素 const elm = element ?? $$CrE({ tagName: 'span', props: { innerText: label }, listeners: [['click', e => callback()]] }); elm.style.color = 'var(--q-primary)'; elm.style.cursor = 'pointer'; // 记录当前页面上最右侧(第一个)按钮以及其右侧#text const first_button = floorbuttons[0]; const first_button_sibling = first_button.element.nextSibling; // 添加按钮数据并按照index排序 const button = { id, wenku: false, element: elm }; floorbuttons.push(button); floorbuttons.sort((b1, b2) => b1.index - b2.index); // 将所有按钮按照新顺序重新添加到页面 floorbuttons.forEach(btn => { // 依次移除所有按钮 if (btn.element.closest('body') === document.body) { // 当按钮不是先前的最右侧按钮时,把右边的" | "也移除掉 btn !== first_button && btn.element.nextSibling.remove(); btn.element.remove(); } }); floorbuttons.forEach((btn, i) => { if (i === 0) { // 第一个按钮添加到原先最右侧按钮右边的#text左侧 first_button_sibling.before(btn.element); } else { // 后续按钮依次添加到上一个按钮之前(左侧) const last_floor_button = floorbuttons[i-1]; last_floor_button.element.before(btn.element); } // 当不是第一个(最右侧)按钮时,右侧添加" | " i > 0 && btn.element.after(' | '); }); return button; } /** * 在楼层左侧用户区下方新增一个按钮 * @param {Floor} floor * @param {Object} options * @param {string} options.id * @param {string} [options.label] - 按钮文字,和element二选一 * @param {number} options.index - 按钮排序位置,仅在非文库自带按钮间排序,文库按钮均在非文库按钮之前 * @param {function} [options.callback] - 按钮点击回调,和element二选一 * @param {function} [options.element] - 按钮元素,和callback二选一 * @returns {FloorButton} */ function addUserButton(floor, { id, label = null, index, callback=null, element=null }) { // 创建/装饰按钮元素 /** @type {HTMLDivElement} */ const container = floor.element.avatar.parentElement; const elm = element ?? $$CrE({ tagName: 'span', props: { innerText: label }, listeners: [['click', e => callback()]] }); elm.style.color = 'var(--q-primary)'; elm.style.cursor = 'pointer'; // 添加按钮数据,按照index重新排序 const button = { id, wenku: false, index, element: elm }; floor.element.userbuttons.push(button); floor.element.userbuttons.sort((b1, b2) => b1.index - b2.index); // 将所有按钮按照新顺序重新添加到页面 const userbuttons = floor.element.userbuttons; userbuttons.forEach(btn => { if (btn.element.closest('body') === document.body) { const prev = btn.element.previousSibling; ['#text', 'BR'].includes(prev.nodeName) && prev.remove(); btn.element.remove(); } }); userbuttons.forEach((btn, i) => { const number = i + 1; if (number % 2 === 1) { // 每行第一个 i !== 0 && container.append($CrE('br')); container.append(btn.element); } else { // 每行第二个 container.append(' | '); container.append(btn.element); } }); return button; } /** * 添加一行内容到指定楼层的左侧用户区域 * @param {Floor} floor - 添加到的楼层 * @param {Object} options * @param {string} options.id - 全局唯一,信息行id * @param {Node | string} options.line - 添加的内容,字符串将转换为文本节点添加 * @param {string} options.base - 一个现有信息行的id,和 position 配合使用,添加到该行的前面或者后面 * @param {'before' | 'after'} options.position - 添加的位置,前面还是后面 */ function addUserLine(floor, { id, line, base, position }) { // 将字符串line转换为TextNode if (typeof line === 'string') { line = document.createTextNode(line); } // 插入到指定行的指定位置 const base_line = parser.getUserLine(floor, base); switch (position) { case 'before': { base_line.element.before(line); base_line.element.before($CrE('br')); break; } case 'after': { base_line.element.after(line); base_line.element.after($CrE('br')); break; } } // 添加到楼层行数据中 /** @type {FloorUserLine} */ const userline = { id, wenku: false, element: line, }; let index = floor.element.userlines.indexOf(base_line); position === 'after' && index++; floor.element.userlines.splice(index, 0, userline); } /** * 更新指定楼层一个已有用户信息行的内容 * @param {Floor} floor - 更新的楼层 * @param {string} id - 信息行id * @param {Node | string} line - 新的信息行内容,字符串将转换为文本节点 */ function updateLine(floor, id, line) { // 将字符串line转换为TextNode if (typeof line === 'string') { line = document.createTextNode(line); } const userline = parser.getUserLine(floor, id); const previous_node = userline.element.previousSibling; previous_node.after(line); userline.element.remove(); userline.element = line; } addStyle(` .plus-highlight { box-shadow: 0 0 10px 1px #75b1df; } .plus-darkmode .plus-highlight { box-shadow: 0 0 10px 1px #0d688b; } `, 'plus-review-transformer') /** * 对楼层应用高亮效果 * @param {Floor} floor */ function applyHighlight(floor) { floor.data.highlight = true; floor.element.root.classList.add('plus-highlight'); $AEL(floor.element.root, 'click', e => clearHighlight(floor), { once: true }); } /** * 对楼层清除高亮效果 * @param {Floor} floor */ function clearHighlight(floor) { floor.data.highlight = false; floor.element.root.classList.remove('plus-highlight'); } return { addFloorButton, addUserButton, addUserLine, updateLine, applyHighlight, clearHighlight, } } }, updater: { desc: '从服务器获取实时评论页面,更新页面内容', dependencies: ['parser', 'transformer'], /** @typedef {Awaited<ReturnType<typeof pool_funcs.updater.func>>} updater */ async func() { /** @type {parser} */ const parser = pool.require('parser'); /** @type {transformer} */ const transformer = pool.require('transformer'); /** * 更新页面时用的提示UI * @satisfies {Record<string, { start: () => void, end: (updated: number) => void }>} */ const UI = { loading: { start() { Quasar.Loading.show({ message: CONST.Text.Review.FloorManager.UpdatingFloors }); }, end() { Quasar.Loading.hide(); } }, notify: { start() { Quasar.Notify.create({ type: 'info', message: CONST.Text.Review.FloorManager.UpdatingFloors, group: 'review.update_floor' }); }, end(updated) { Quasar.Notify.create({ type: 'success', message: CONST.Text.Review.FloorManager.FloorUpdated, caption: replaceText( CONST.Text.Review.FloorManager.FloorUpdatedCaption, { '{Updated}': updated }, ), group: 'review.update_floor' }); } } }; /** * 获取一个评论页面,并解析 * @param {number} rid * @param {number | 'last'} [page] * @returns {Promise<{ floors: Floor[], pagelink: HTMLDivElement }>} */ async function fetch(rid, page) { const doc = await utils.requestDocument({ method: 'GET', url: `/modules/article/reviewshow.php?rid=${rid}&page=${page ?? 1}`, }); const floors = parser.parseAll(doc); const pagelink = $(doc, '#pagelink'); return { floors, pagelink }; } /** * 从文库服务器获取当前书评页面的最新版本,并更新到页面中,同时也更新floors全局实例 * 注意:只有在楼层标题或内容有所改变时,才会更新对应楼层 * @param {keyof typeof UI} [ui='notify'] - 采用什么UI提示用户页面楼层正在更新;默认"notify" * @param {number | 'last'} [page] - 需要加载(更新到)的页面页码,默认为当前页码;默认当前页码;注意:这里即使填写了"last",最终url也会显示对应的数字格式的页码,而不是"page=last" * @param {boolean} [highlight=true] - 是否高亮发生了更改的楼层;默认为true * @param {'push' | 'replace' | 'none'} [state='replace'] - 在页码改变时,是添加新浏览历史、修改现有浏览状态还是不改变浏览历史和状态;默认"replace"(修改现有);注意:当页码没有改变时,无论填写什么,都既不会添加新浏览记录,又不会改变现有浏览记录 */ async function update(ui = 'notify', page=null, highlight=true, state='replace') { UI[ui].start(); // 获取最新的页面楼层 const search = new URLSearchParams(location.search); const rid = parseInt(search.get('rid'), 10); const cur_page = parseInt($('#pagelink > strong').innerText.trim(), 10); page = page ?? cur_page; const { floors: new_floors, pagelink: new_pagelink } = await fetch(rid, page); // 旧楼层列表比新楼层列表长时,去除旧楼层尾部多出来的楼层 if (floors.length > new_floors.length) { for (let i = new_floors.length; i < floors.length; i++) { floors[i].element.root.remove(); } floors.splice(new_floors.length, floors.length - new_floors.length); } // 和页面现有楼层比对,对有内容更新的楼层进行更新 const updated_floors = []; new_floors.forEach((new_floor, i) => { const old_floor = floors[i]; // 跳过无内容更新的楼层 if ( old_floor && old_floor.data.number === new_floor.data.number && old_floor.data.content === new_floor.data.content && old_floor.data.title === new_floor.data.title ) { return; } // 更新楼层 if (old_floor) { old_floor.element.root.before(new_floor.element.root); old_floor.element.root.remove(); } else { // 新增楼层 floors[floors.length-1].element.root.after(new_floor.element.root); } // 对新楼层应用高亮效果 highlight && transformer.applyHighlight(new_floor); // 更新楼层实例数据 old_floor ? floors.splice(floors.indexOf(old_floor), 1, new_floor) : floors.push(new_floor); // 记录新楼层 updated_floors.push(new_floor); }); // 同时更新一下页脚翻页指示器 const old_pagelink = $('#pagelink'); old_pagelink.before(new_pagelink); old_pagelink.remove(); // 如果页码有改变,添加/改变浏览历史 const num_page = page === 'last' ? parseInt($('#pagelink > strong').innerText.trim(), 10) : page; num_page !== cur_page && ({ 'push': history.pushState.bind(history), 'replace': history.replaceState.bind(history), 'none': () => {}, })[state](null, '', `/modules/article/reviewshow.php?rid=${rid}&page=${num_page}`); // 广播楼层更新事件 messager.dispatchEvent(new CustomEvent('update', { detail: { floors: updated_floors, } })); UI[ui].end(updated_floors.length); } return { fetch, update, }; } }, }; const { promise, pool } = utils.loadFuncInNewPool(pool_funcs); await promise; /** @type {parser} */ const parser = pool.require('parser'); const floors = parser.parseAll(); /** * 将传入的方法应用于全部的Floor,包括一开始就在页面上的和后来通过更新等方式添加到页面上的 * @param {(floor: Floor) => any} func */ function applyToAllFloors(func) { floors.forEach(floor => func(floor)); $AEL(messager, 'update', e => e.detail.floors.forEach(floor => func(floor)) ); } return { /** 全局唯一 floors 数据实例,一切涉及楼层的操作都应围绕此数据实例进行 */ floors, /** @type {parser} */ parser: pool.require('parser'), /** @type {transformer} */ transformer: pool.require('transformer'), /** @type {updater} */ updater: pool.require('updater'), applyToAllFloors, } } }, citing: { desc: '楼层引用功能', dependencies: 'FloorManager', params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], /** @typedef {Awaited<ReturnType<typeof pool_funcs.citing.func>>} citing */ func(GM_setValue, GM_getValue, GM_addValueChangeListener) { GM_getValue = utils.defaultedGet({ no_content: false, pangu: true, select: false, }, GM_getValue); configs.registerSettings('review', [{ type: 'boolean', label: CONST.Text.Review.Settings.Pangu, caption: CONST.Text.Review.Settings.PanguCaption, key: 'pangu', get() { return GM_getValue('pangu'); }, set(val) { return GM_setValue('pangu', val); }, }, { type: 'boolean', label: CONST.Text.Review.Settings.NoContent, caption: CONST.Text.Review.Settings.NoContentCaption, key: 'no_content', get() { return GM_getValue('no_content'); }, set(val) { return GM_setValue('no_content', val); }, }, { type: 'boolean', label: CONST.Text.Review.Settings.Select, caption: CONST.Text.Review.Settings.SelectCaption, key: 'select', get() { return GM_getValue('select'); }, set(val) { return GM_setValue('select', val); }, }], GM_addValueChangeListener); /** @type {FloorManager} */ const FloorManager = pool.require('FloorManager'); // 为每个楼层添加引用按钮 FloorManager.applyToAllFloors(addCiteButton); /** * 为给定楼层添加引用按钮 * @param {Floor} floor */ function addCiteButton(floor) { FloorManager.transformer.addFloorButton(floor, { id: 'cite', label: CONST.Text.Review.Cite.Cite, index: 1, callback: () => cite(floor) }); } /** * 引用某个楼层到回帖输入框中 * @param {Floor} floor - 引用的楼层 * @param {boolean} [no_content] - 是否仅引用楼号,省略则使用储存的配置 * @param {boolean} [pangu] - 是否保证和周围文字之间有且仅有一个空格,省略则使用储存的配置 * @param {boolean} [select] - 是否选中引用部分文字,省略则使用储存的配置 */ function cite(floor, no_content=null, pangu=null, select=null) { no_content = no_content ?? GM_getValue('no_content'); pangu = pangu ?? GM_getValue('pangu'); select = select ?? GM_getValue('select'); /** @type {HTMLTextAreaElement | null} */ const textarea = $('#pcontent'); if (!textarea) { return; } // 插入引用内容 const bbcode = no_content ? `[url=${floor.data.url}]#${floor.data.number}[/url]` : `[url=${floor.data.url}]#${floor.data.number}[/url] [quote]${floor.data.content}[/quote]\n`; utils.insertText(textarea, bbcode, pangu, select); // 自动聚焦到输入框的同时平滑滚动到输入框位置 // .focus会自动跳转到元素位置,因此需要先复位到.focus前再开始平滑滚动 // 虽然有 preventScroll 选项,但是这个选项在安卓上似乎不可用 const [orig_x, orig_y] = [window.scrollX, window.scrollY]; textarea.focus({ preventScroll: true }); window.scroll(orig_x, orig_y); textarea.scrollIntoView({ behavior: 'smooth' }); } return { /** @type {boolean} */ get pangu() { return GM_getValue('pangu'); }, set pangu(val) { return GM_setValue('pangu', val); }, /** @type {boolean} */ get no_content() { return GM_getValue('no_content'); }, set no_content(val) { return GM_setValue('no_content', val); }, /** @type {boolean} */ get select() { return GM_getValue('select'); }, set select(val) { return GM_setValue('select', val); } }; } }, floorjump: { desc: '点击页面内楼层链接,直接跳转到页面位置,而不是重新加载页面到该位置', dependencies: 'FloorManager', params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], func(GM_setValue, GM_getValue, GM_addValueChangeListener) { GM_getValue = utils.defaultedGet({ jump: true }, GM_getValue); /** @type {FloorManager} */ const FloorManager = pool.require('FloorManager'); const floors = FloorManager.floors; // 拦截<a>点击事件,根据设置决定是否跳转 const content = $('#content'); $AEL(content, 'click', e => { // 检查是否开启跳转功能 if (!GM_getValue('jump')) { return; } // 按下Ctrl/Meta/Shift键代表用户显式指定在新标签页/窗口打开,不执行跳转 if (e.ctrlKey || e.shiftKey || e.metaKey) { return; } // 检查是否点击到了一个指向楼层的链接 /** @type {null | HTMLAnchorElement} */ const a = e.target.closest('a[href*="#yid"]'); if ( !a || a.pathname !== '/modules/article/reviewshow.php' || !/^#yid\d+$/.test(a.hash) ) { return; } // 检查链接是否在某楼层正文内 if (floors.every(floor => !floor.element.content.contains(a))) { return; } // 检查目标楼层是否在页面内 /** @type {Floor} */ const floor = floors.find(floor => // 防止 rid 参数不存在,因此选择将floor的rid转换为字符串进行比较,而不是将a的rid参数转换为数字 floor.data.rid.toString() === new URLSearchParams(a.search).get('rid') && // 前面判断过a.hash符合yid\d+的格式,可以直接match取值 floor.data.yid.toString() === a.hash.match(/\d+/)[0] ); if (!floor) { return; } // 检查通过,跳转 e.preventDefault(); floor.element.root.scrollIntoView({ behavior: 'smooth' }); }); // 注册(不可用)设置项 configs.registerSettings('review', [{ type: 'boolean', label: CONST.Text.Review.Settings.FloorJump, caption: CONST.Text.Review.Settings.FloorJumpCaption, key: 'floorjump', get() { return GM_getValue('jump'); }, set(val) { return GM_setValue('jump', val); }, }], GM_addValueChangeListener); } }, pagejump: { desc: '点击右下角页码切换,直接页面内更新,而不是重建加载页面到该页码', dependencies: ['FloorManager'], params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], func(GM_setValue, GM_getValue, GM_addValueChangeListener) { /** @type {FloorManager} */ const FloorManager = pool.require('FloorManager'); GM_getValue = utils.defaultedGet({ jump: true, }, GM_getValue); // 拦截#pagelink > a点击事件,根据设置决定是否页面内更新 // 因为 #pagelink 会随着页面内更新而更新改变,所以要把事件监听器添加到父元素上 const pagelink_parent = $('#pagelink').parentElement; $AEL(pagelink_parent, 'click', e => { // 检查是否开启跳转功能 if (!GM_getValue('jump')) { return; } // 按下Ctrl/Meta/Shift键代表用户显式指定在新标签页/窗口打开,不执行跳转 if (e.ctrlKey || e.shiftKey || e.metaKey) { return; } // 检查是否点击到了一个指向新页码的链接 /** @type {null | HTMLAnchorElement} */ const a = e.target.closest('a[href^="/modules/article/reviewshow.php"]'); if ( !a || a.pathname !== '/modules/article/reviewshow.php' ) { return; } // 页面内更新 e.preventDefault(); const search = new URLSearchParams(a.search); const page = parseInt(search.get('page'), 10); FloorManager.updater.update('loading', page, false, 'push'); }); // 当点击浏览器后退按钮时,更新页面 $AEL(window, 'popstate', e => { const search = new URLSearchParams(location.search); const page = search.get('page'); GM_getValue('jump') ? FloorManager.updater.update('loading', page, false, 'none') : location.reload(); }); // 注册(不可用)设置项 configs.registerSettings('review', [{ type: 'boolean', label: CONST.Text.Review.Settings.PageJump, caption: CONST.Text.Review.Settings.PageJumpCaption, key: 'pagejump', get() { return GM_getValue('jump'); }, set(val) { return GM_setValue('jump', val); }, }], GM_addValueChangeListener); } }, replyinpage: { desc: '页面内免刷新发评论', detectDom: '.main.m_foot', dependencies: ['FloorManager'], params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], /** @typedef {Awaited<ReturnType<typeof pool_funcs.replyinpage.func>>} replyinpage */ async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { /** @type {FloorManager} */ const FloorManager = pool.require('FloorManager'); GM_getValue = utils.defaultedGet({ enabled: true, }, GM_getValue); configs.registerSettings('review', [{ type: 'boolean', label: CONST.Text.Review.Settings.ReplyInPage, caption: CONST.Text.Review.Settings.ReplyInPageCaption, key: 'replyinpage', get() { return GM_getValue('enabled'); }, set(val) { return GM_setValue('enabled', val); }, }], GM_addValueChangeListener); const form = $('form[name="frmreview"]'); form && hookSubmit(form); /** * 将评论编辑器的表单提交改为ajax请求,并在请求完成后更新页面楼层 * @param {HTMLFormElement} form * @param {(form: HTMLFormElement) => any} onSend - 评论发送完成回调 * @param {boolean} to_last - 发送完毕更新页面楼层是否更新到最后一页,如果为否则更新到当前页 */ function hookSubmit(form, onSend, to_last=true) { let submit_ongoing = false; $AEL(form, 'submit', async e => { if (!GM_getValue('enabled')) { return; } if (submit_ongoing) { return; } // 拦截默认行为 e.preventDefault(); const ReplyInPage = CONST.Text.Review.ReplyInPage; // 不允许发送空数据 const formdata = new FormData(form); if (!formdata.get('pcontent').length) { Quasar.Notify.create({ type: 'error', message: ReplyInPage.NoEmptyContent, caption: ReplyInPage.NoEmptyContentCaption, }); return; } // 发送评论 submit_ongoing = true; Quasar.Loading.show({ message: ReplyInPage.SendingReply }); const data = utils.serializeFormData(formdata); const doc = await utils.requestDocument({ method: 'POST', url: form.getAttribute('action'), data, headers: { 'content-type': 'application/x-www-form-urlencoded' } }); Quasar.Loading.hide(); submit_ongoing = false; // 发送完成提示 const is_block = !!$(doc, '.block'); Quasar.Notify.create({ type: 'success', message: ReplyInPage.ReplySent, caption: is_block ? $(doc, '.blocktitle').innerText : undefined, actions: is_block ? [{ label: ReplyInPage.SentStatusDetails, async handler() { // 使用文库返回的block作为详情弹窗内容 const block = $(doc, '.block').cloneNode(true); block.classList.add('plus-preserve-border'); // 移除脚注 $(block, '.blocknote')?.remove(); // 点击任意<a>链接时,什么都不做(拦截默认行为与事件处理器) [...$All(block, 'a')].forEach(a => $AEL(a, 'click', e => e.ctrlKey || e.metaKey || e.shiftKey || destroyEvent(e), { capture: true } ) ); // 点击返回时,关闭弹窗并重新聚焦到编辑器 [...$All(block, 'a[href="javascript:history.back(1)"]')].forEach(a => $AEL(a, 'click', e => { dialog.hide(); setTimeout(() => $(form, '#pcontent').focus()); }, { capture: true }) ); // 点击关闭此窗口时,关闭弹窗 [...$All(block, 'a[href="javascript:window.close()"]')].forEach(a => $AEL(a, 'click', e => { dialog.hide(); }, { capture: true }) ); // Quasar Dialog 展示详情 const dialog = Quasar.Dialog.create({ message: '<div id="plus-reply-detail"></div>', html: true, ok: ReplyInPage.DetailsOk, }); (await detectDom('#plus-reply-detail')).append(block); } }] : [], group: 'review.replyinpage.reply-sent', }); // 回调 onSend && onSend(form); // 更新页面楼层 const page = to_last ? 'last' : new URLSearchParams(location.search).get('page') ?? 1; await FloorManager.updater.update('loading', page, true, 'push'); }); } return { hookSubmit, }; }, }, editinpage: { desc: '编辑楼层功能页面内完成', dependencies: ['FloorManager', 'replyinpage'], params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], /** @typedef {Awaited<ReturnType<typeof pool_funcs.editinpage.func>>} editinpage */ async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { /** @type {FloorManager} */ const FloorManager = pool.require('FloorManager'); /** @type {replyinpage} */ const replyinpage = pool.require('replyinpage'); /** @type {darkmode} */ const darkmode = await require('darkmode', true); /** @type {ubbeditor} */ const ubbeditor = await require('ubbeditor', true); GM_getValue = utils.defaultedGet({ enabled: true, }, GM_getValue); configs.registerSettings('review', [{ type: 'boolean', label: CONST.Text.Review.Settings.EditInPage, caption: CONST.Text.Review.Settings.EditInPageCaption, key: 'editinpage', get() { return GM_getValue('enabled'); }, set(val) { return GM_setValue('enabled', val); }, }], GM_addValueChangeListener); FloorManager.applyToAllFloors(hookEdit); /** * 将楼层的编辑按钮(如果有)点击后改为在页面内编辑,而不是打开一个新页面 * @param {Floor} floor */ function hookEdit(floor) { const edit = FloorManager.parser.getFloorButton(floor, 'edit'); const yid = floor.data.yid; if (!edit) { return; } $AEL(edit.element, 'click', async e => { if (!GM_getValue('enabled')) { return; } // 按下Ctrl/Meta/Shift时,为用户显式指定在新标签页/新窗口打开,不拦截 if (e.ctrlKey || e.metaKey || e.shiftKey) { return; } // 阻止打开新页面 e.preventDefault(); // 获取编辑框部分html const url = `/modules/article/reviewedit.php?yid=${yid}&ajax_gets=jieqi_contents`; const doc = await utils.requestDocument({ method: 'GET', url, }); const editor = $(doc, 'form[name="frmreview"]').cloneNode(true); [...$All(editor, 'script')].forEach(s => s.remove()); const editor_html = editor.outerHTML; // 获取页面资源 const [editor_js, common_js] = await Promise.all([ utils.requestText({ method: 'GET', url: '/scripts/ubbeditor_gbk.js' }), utils.requestText({ method: 'GET', url: '/scripts/common.js' }), ]); // 合成整体html /* const body_html = [ // 文档编码 `<meta charset="${ document.characterSet }">`, // 文库自带CSS '<link rel="stylesheet" href="/themes/wenku8/style.css">', // 深色模式CSS darkmode.getPageCSS(url).map(css => `<style>${css}</style>`).join('\n'), // UBBEditor所用loadJS依赖 '<script src="/scripts/common.js"></script>', // 编辑器和表单 editor_html, ].join('\n'); */ const body_html = [ // 文库自带CSS '<link rel="stylesheet" href="/themes/wenku8/style.css">', // 深色模式CSS darkmode.getPageCSS(url).map(css => `<style>${css}</style>`).join('\n'), // JS依赖 `<script>${ common_js }</script>`, // 编辑器和表单 editor_html, // UBBEditor `<script>${ editor_js };\nUBBEditor.Create("pcontent");</script>`, ].join('\n'); // 深色模式 const body_class = darkmode.actual_enabled ? 'plus-darkmode' : ''; const html = ` <body class="${body_class}" style="overflow: hidden;" > ${body_html} </body> `; darkmode.onToggle(enabled => { iframe.contentDocument?.body.classList[enabled ? 'add' : 'remove']('plus-darkmode') }); // 包装到iframe中 /** @type {HTMLIFrameElement} */ const iframe = $$CrE({ tagName: 'iframe', props: { srcdoc: html, }, styles: { border: 'none', }, listeners: [[ 'load', e => { const doc = iframe.contentDocument; // 调整宽高 function resize() { iframe.width = doc.body.scrollWidth; iframe.height = doc.body.scrollHeight; } resize(); const observer = new ResizeObserver(entries => resize()); observer.observe(iframe.contentDocument.body); // 这里无法在onDismiss中unobserve,因为onDismiss时iframe的body已不存在 //dialog.onDismiss(() => observer.unobserve(iframe.contentDocument.body)); // 编辑器修复与增强 const form = $(doc, 'form[name="frmreview"]'); replyinpage.hookSubmit(form, () => dialog.hide(), false); ubbeditor.enhance(form); }, ]] }); // 在 Quasar Dialog 中展示 const dialog = Quasar.Dialog.create({ message: `<div id="plus-edit-dialog"></div>`, html: true, ok: false, cancel: false, style: { width: 'fit-content', height: 'fit-content', maxWidth: 'none', }, }); (await detectDom('#plus-edit-dialog')).append(iframe); }); } }, }, autorefresh: { desc: '自动刷新楼层', dependencies: ['FloorManager'], params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { /** @type {FloorManager} */ const FloorManager = pool.require('FloorManager'); GM_getValue = utils.defaultedGet({ enabled: false, refresh_last: true, }, GM_getValue); const Settings = CONST.Text.Review.Settings; configs.registerSettings('review', [{ type: 'boolean', label: Settings.AutoRefresh, caption: Settings.AutoRefreshCaption, key: 'autorefresh', get() { return GM_getValue('enabled'); }, set(val) { GM_setValue('enabled', val)}, }, { type: 'boolean', label: Settings.RefreshToLast, caption: Settings.RefreshToLastCaption, key: 'refresh_last', get() { return GM_getValue('refresh_last'); }, set(val) { GM_setValue('refresh_last', val); }, }], GM_addValueChangeListener); setInterval( () => GM_getValue('enabled') && document.visibilityState === 'visible' && (GM_getValue('refresh_last') ? FloorManager.updater.update('notify', 'last', true, 'replace') : FloorManager.updater.update('notify', null, true, 'replace')), CONST.Internal.ReviewAutoRefreshInterval, ); }, }, beautifier: { desc: '页面样式修复增强', detecoDom: 'head', async func() { // 回复内引用、代码文字最大宽度限制 addStyle(` pre { white-space: pre-wrap; /* 保留格式但允许自动换行 */ word-break: break-word; /* 即使没有空格也能断句 */ overflow-wrap: break-word; /* 兼容性增强 */ } `); // 回复内图片最大宽度限制 detectDom({ selector: '.divimage > img', /** @param {HTMLImageElement} img */ callback(img) { const tryResize = () => img.naturalWidth ? resize() : setTimeout(() => tryResize(), CONST.Internal.ReviewResizeInterval); tryResize(); function resize() { img.style.width = `min(100%, ${img.naturalWidth}px)`; } } }); } }, }; const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { GM_setValue, GM_getValue, GM_addValueChangeListener }); await promise; return { /** @type {FloorManager} */ FloorManager: pool.require('FloorManager'), /** @type {citing} */ citing: pool.require('citing'), messager, _types: { /** @type {Floor} */ Floor: {}, } } } }, ubbeditor: { desc: '编辑器修复与增强', dependencies: ['utils'], /** @typedef {Awaited<ReturnType<typeof functions.ubbeditor.func>>} ubbeditor */ async func() { /** @type {utils} */ const utils = require('utils'); // 自动将修复与增强功能应用于已知的页面内自带UBBEditor实例 const pages = [{ checkers: { type: 'path', value: '/modules/article/reviewshow.php' }, selector: 'form[name="frmreview"]' }, { checkers: [{ type: 'regpath', value: /\/book\/\d+\.htm/ }, { type: 'startpath', value: '/modules/article/articleinfo.php' }], selector: 'form[name="frmreview"]' }, { checkers: { type: 'path', value: '/modules/article/reviews.php' }, selector: 'form[name="frmreview"]' }, { checkers: { type: 'path', value: '/modules/article/reviewedit.php' }, selector: 'form[name="frmreview"]' }]; pages.forEach(page => FunctionLoader.testCheckers(page.checkers) && detectDom(page.selector).then(form => enhance(form))); /** * 将修复与增强功能应用于UBBEditor实例 * @param {HTMLFormElement} form - 存放UBBEditor的form,通常是[name="frmreview"],无需等待其中的UBBEditor加载初始化完毕 * @returns {Promise<void>} */ async function enhance(form) { await Promise.all([ // 样式表式修复 detectDom(form.ownerDocument, 'head').then(head => addStyle(head, ` textarea[name="pcontent"] { padding: 2px; width: 90%; } `, 'plus-ubbeditor-enhance')), // 重写插入图片 detectDom(form, '#menuItemInsertImage').then( /** @param {HTMLInputElement} input */ input => $AEL(input, 'click', async e => { e.stopImmediatePropagation(); const InsertImage = CONST.Text.Review.UBBEditor.InsertImage; let url = await prompt({ message: InsertImage.InputUrl + '<br>' + InsertImage.UrlFormatTip, title: InsertImage.Title, html: true, ok: InsertImage.Ok, cancel: InsertImage.Cancel, isValid(url) { return isValidImageUrl(url.trim()); }, }); if (url === null) { return; } url = url.trim(); const textarea = $('#pcontent'); utils.insertText(textarea, url, true); textarea.focus(); }, { capture: true }) ), // 重写插入链接 detectDom(form, '#menuItemInsertUrl').then( /** @param {HTMLInputElement} input */ input => $AEL(input, 'click', async e => { e.stopImmediatePropagation(); const InsertUrl = CONST.Text.Review.UBBEditor.InsertUrl; let url = await prompt({ message: InsertUrl.InputUrl + '<br>' + InsertUrl.UrlFormatTip, title: InsertUrl.Title, html: true, ok: InsertUrl.Ok, cancel: InsertUrl.Cancel, isValid(url) { return isValidUrl(url.trim()); }, }); if (url === null) { return; } url = url.trim(); const textarea = $('#pcontent'); utils.insertText(textarea, `[url=${url}]${url}[/url]`); textarea.focus(); }, { capture: true }) ), // Ctrl/Meta + Enter键发表书评 detectDom(form, '#pcontent').then(pcontent => { $AEL(pcontent, 'keydown', e => { const os = GM_info.platform?.os ?? GM_info.userAgentData.platform; const is_mac = ['darwin', 'osx', 'mac'].some(str => os.includes(str)); if ((is_mac ? e.metaKey : e.ctrlKey) && e.code === 'Enter') { $(form, 'input[type="submit"][name="Submit"]')?.click(); } }); }), // 自适应高度 detectDom(form, '#pcontent').then( /** @param {HTMLTextAreaElement} pcontent */ pcontent => $AEL(pcontent, 'input', e => { const cur_height = parseInt(getComputedStyle(pcontent).height.match(/\d+/)[0], 10); // 跟deepseek学的:先设为auto以便正确计算pcontent.scrollHeight pcontent.style.height = 'auto'; // 根据当前输入框内部滚动高度和预设的上下限确定输入框新高度 let target_height = Math.min( CONST.Internal.EditorHeight.Max, Math.max( CONST.Internal.EditorHeight.Min, pcontent.scrollHeight ) ); // 仅自动增高,不自动缩小 target_height = cur_height < target_height ? target_height : cur_height; // 设置高度 pcontent.style.height = `${target_height}px`; }) ), ]); } /** * 检查给定链接是否为符合文库书评语法格式的图片链接 * @param {string} url * @returns {boolean} */ function isValidImageUrl(url) { const prefix_valid = url.startsWith('http://') || url.startsWith('https://'); const suffix_valid = /\.(jpe?g|a?png|gif|webp)$/.test(url); const url_valid = prefix_valid && suffix_valid; return url_valid; } /** * 检查给定链接是否为符合文库书评语法格式的链接 * @param {string} url * @returns {boolean} */ function isValidUrl(url) { const prefix_valid = url.startsWith('http://') || url.startsWith('https://'); const url_valid = prefix_valid; return url_valid; } /** * Quasar Dialog 实现的prompt * @param {Object} options * @param {string} options.message - 提示文本 * @param {string} [options.title] - 输入框标题 * @param {boolean} [options.html=false] - 提示文本是否为html(不安全) * @param {string} [options.ok] - 确认按钮文本 * @param {string} [options.cancel] - 取消按钮文本 * @param {string} [options.model=''] - 输入框初始值 * @param {(val: string) => boolean} options.isValid - 验证输入数据是否合法的方法 * @returns {Promise<string | null>} */ function prompt({ message, title, html, ok, cancel, model, isValid }) { const { promise, resolve } = Promise.withResolvers(); const options = { message, ok: { color: 'primary', }, cancel: { color: 'secondary', }, prompt: { model: model ?? '', isValid, }, }; title && (options.title = title); html && (options.html = html); ok && (options.ok.label = ok); cancel && (options.cancel.label = cancel); Quasar.Dialog.create(options).onOk(text => resolve(text)).onCancel(() => resolve(null)); return promise; } return { enhance }; } }, userpage: { desc: '用户信息页相关功能,目前就一个DOM解析器', checkers: { type: 'path', value: '/userpage.php' }, dependencies: ['utils'], /** @typedef {Awaited<ReturnType<typeof functions.userpage.func>>} userpage */ async func() { /** @type {utils} */ const utils = require('utils'); // 注:这里的对象并非完整,按需开发即可 /** * 标准页面对象,由页面解析器生成 * @typedef {Object} UserPage * @property {UserElement} element * @property {UserData} data */ /** * {@link UserPage} 类型中的DOM元素 * @typedef {Object} UserElement * @property {HTMLDivElement} info - 会员信息block * @property {HTMLAnchorElement} avatar - 头像Img * @property {HTMLElement} name - 昵称strong * @property {UserLine[]} userlines - 会员信息板块信息行集合 * @property {UserButton[]} userbuttons - 会员信息板块操作按钮集合 * @property {HTMLUListElement} linecontainer - 会员信息板块信息行的父元素容器 * @property {HTMLUListElement} buttoncontainer - 会员信息板块操作按钮的父元素容器 */ /** * {@link UserPage} 类型中的数据 * @typedef {Object} UserData * @property {User} user */ /** * {@link UserElement} 类型中的一行信息行 * @typedef {Object} UserLine * @property {string} id - 信息行id,全局唯一 * @property {boolean} wenku - 是否为文库页面自带行 * @property {number} index - 按钮排序位置,升序排列,文库自带均为负数,新增按钮均为正数 * @property {HTMLLIElement} element - 对应的DOM节点 */ /** * {@link UserElement} 类型中的一个操作按钮 * @typedef {Object} UserButton * @property {string} id - 按钮id,全局唯一 * @property {boolean} wenku - 是否为文库页面自带按钮 * @property {number} index - 按钮排序位置,升序排列,文库自带均为负数,新增按钮均为正数 * @property {HTMLElement} element - 对应的DOM节点,应为li内部的按钮元素而非li节点 */ /** * {@link UserData} 类型中的用户数据 * @typedef {Object} User * @property {number} id * @property {string} name */ const pool_funcs = { PageManager: { desc: '管理页面对象实例及其解析与修改', /** @typedef {Awaited<ReturnType<typeof pool_funcs.PageManager.func>>} PageManager */ async func() { const pool_funcs = { parser: { desc: 'DOM解析器', /** @typedef {Awaited<ReturnType<typeof pool_funcs.parser.func>>} parser */ func() { /** * 将Document解析为标准用户页对象 * 仅可解析未被修改的原始文库页面 * @param {Document} [doc=document] - 被解析的文档,省略则默认为当前页面文档 * @returns {UserPage} */ function parse(doc = document) { const element = parseElement(doc); const data = parseData(element); return { element, data } } /** * 将Document解析为标准用户页对象的元素部分 * 仅可解析未被修改的原始文库页面 * @param {Document} [doc=document] - 被解析的文档,省略则默认为当前页面文档 * @returns {UserElement} */ function parseElement(doc = document) { const info = $(doc, '#left > .block:first-child'); const avatar = $(info, '.blockcontent .avatars'); const name = $(info, '.blockcontent .ulrow > li:nth-child(2)'); const linecontainer = $(info, '.blockcontent .ulrow'); const buttoncontainer = $(info, '.blockcontent > div > ul:nth-of-type(2)'); const userlines = getUserLines(); const userbuttons = getUserButtons(); return { info, avatar, name, userlines, userbuttons, linecontainer, buttoncontainer } function getUserLines() { return [...$All(info, '.blockcontent .ulrow > li')] .filter(li => !li.children.length) .map( /** * @param {HTMLLIElement} li * @param {number} i * @returns {UserLine} */ (li, i, list_items) => ({ id: ['type', 'level'][i], wenku: true, index: i - list_items.length, element: li }) ); } function getUserButtons() { return [...$All(info, '.blockcontent > div > :nth-child(2) > li > a')] .map( /** * @param {HTMLAnchorElement} a * @param {number} i * @returns {UserButton} */ (a, i, anchors) => ({ id: ['message', 'friend', 'detail'][i], wenku: true, index: i - anchors.length, element: a }) ) } } /** * 从标准用户页对象元素部分解析数据 * 仅可解析未被修改的原始文库页面 * @param {UserElement} element - 被解析的文档,省略则默认为当前页面文档 * @returns {UserData} */ function parseData(element) { /** @type {User} */ const user = { id: parseInt( new URLSearchParams( element.userbuttons .find(b => b.id === 'detail') .element.search ).get('id'), 10 ), name: element.name.innerText.trim() }; return { user }; } /** * 根据id获取指定信息行 * @param {UserPage} page * @param {string} id * @returns {UserLine | null} */ function getUserLine(page, id) { return page.element.userlines.find(l => l.id === id); } /** * 根据id获取指定操作按钮 * @param {UserPage} page * @param {string} id * @returns {UserButton | null} */ function getUserButton(page, id) { return page.element.userbuttons.find(b => b.id === id); } return { parse, getUserLine, getUserButton, } } }, transformer: { desc: '页面修改器', dependencies: 'parser', /** @typedef {Awaited<ReturnType<typeof pool_funcs.transformer.func>>} transformer */ func() { /** @type {parser} */ const parser = pool.require('parser'); /** * 在用户区下方新增一个按钮 * @param {UserPage} page * @param {Object} options * @param {string} options.id * @param {string} [options.label] - 按钮文字,和element二选一 * @param {string} [options.index] - 按钮的排序位置 * @param {function} [options.callback] - 按钮点击回调,和element二选一 * @param {HTMLElement} [options.element] - 按钮元素,和callback二选一 * @returns {FloorButton} */ function addUserButton(page, { id, label = null, index, callback = null, element = null }) { // 创建按钮元素 /** @type {HTMLDivElement} */ const container = $$CrE({ tagName: 'li', styles: { cssText: 'width:49%;float:left;' } }); const elm = element ?? $$CrE({ tagName: 'span', props: { innerText: label }, listeners: [['click', e => callback()]] }); elm.style.color = 'var(--q-primary)'; elm.style.cursor = 'pointer'; container.append(elm); // 添加按钮数据 const button = { id, wenku: false, index, element: elm }; const userbuttons = page.element.userbuttons; userbuttons.push(button); // 按照index排序并添加到页面 resortButtons(page); return button; } /** * 从用户区下方移除一个按钮 * @param {UserPage} page * @param {string} id * @returns {boolean} 是否移除成功,不成功可能是因为指定id的按钮不存在 */ function removeUserButton(page, id) { const userbuttons = page.element.userbuttons; const index = userbuttons.findIndex(btn => btn.id === id); if (index < 0) { return; } const button = userbuttons[index]; userbuttons.splice(index, 1); button.element.parentElement.remove(); // 按照index排序 resortButtons(page); } /** * 将page中的用户区的按钮按照index排序并重新添加到页面 * @param {UserPage} page */ function resortButtons(page) { const userbuttons = page.element.userbuttons; // 按照index排序 userbuttons.sort((b1, b2) => b1.index - b2.index); // 按照排好的顺序重新添加到页面 const parent = page.element.buttoncontainer; userbuttons.forEach(btn => parent.append(btn.element.parentElement)); } /** * 添加一行内容到会员信息的信息行中 * @param {UserPage} page - 用户页对象 * @param {Object} options * @param {string} options.id - 全局唯一,信息行id * @param {Node | string} options.line - 添加的内容,字符串将转换为文本节点添加 * @param {string} options.index - 信息行的排序位置 */ function addUserLine(page, { id, line, index }) { // 将字符串line转换为TextNode if (typeof line === 'string') { line = document.createTextNode(line); } // 使用li包装 const li = $CrE('li'); li.append(line); // 添加到楼层行数据中 /** @type {UserLine} */ const userline = { id, wenku: false, index, element: li, }; page.element.userlines.push(userline); // 按照index排序并添加到页面 resortLines(page); } /** * 更新一个已有用户信息行的内容 * @param {UserPage} page - 更新的楼层 * @param {string} id - 信息行id * @param {Node | string} line - 新的信息行内容,字符串将转换为文本节点 */ function updateLine(page, id, line) { // 将字符串line转换为TextNode if (typeof line === 'string') { line = document.createTextNode(line); } // 用li包装 const li = $CrE('li'); li.append(line); // 更新 const userline = parser.getUserLine(page, id); const previous_node = userline.element.previousSibling; previous_node.after(li); userline.element.remove(); userline.element = li; } /** * 移除一个已有用户信息行的内容 * @param {UserPage} page - 更新的楼层 * @param {string} id - 信息行id * @returns */ function removeLine(page, id) { const userline = parser.getUserLine(page, id); if (!userline) { return; } userline.element.remove(); const index = page.element.userlines.indexOf(userline); page.element.userlines.splice(index, 1); } /** * 将page中的用户区的信息行按照index排序并重新添加到页面 * @param {UserPage} page */ function resortLines(page) { const userlines = page.element.userlines; // 按照index排序 userlines.sort((b1, b2) => b1.index - b2.index); // 按照排好的顺序重新添加到页面 const parent = page.element.linecontainer; userlines.forEach(btn => parent.append(btn.element)); } return { addUserButton, removeUserButton, addUserLine, updateLine, removeLine }; } } }; const { promise, pool } = utils.loadFuncInNewPool(pool_funcs); await promise; /** @type {parser} */ const parser = pool.require('parser'); /** 当前页面的唯一页面对象实例,所有对页面的访问和修改都应围绕此实例进行 */ const page = parser.parse(); return { page, /** @type {parser} */ parser: pool.require('parser'), /** @type {transformer} */ transformer: pool.require('transformer'), } } }, }; const { promise, pool } = utils.loadFuncInNewPool(pool_funcs); await promise; return { /** @type {PageManager} */ PageManager: pool.require('PageManager'), } } }, userremark: { desc: '对用户进行备注的功能', checkers: [{ // 书评 type: 'path', value: '/modules/article/reviewshow.php' }, { // 用户主页 type: 'path', value: '/userpage.php' }], dependencies: ['debugging', 'utils', 'configs'], params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { /** @type {utils} */ const utils = require('utils'); /** @type {configs} */ const configs = require('configs'); // 如果是发评论返回的提示页面,不继续运行 if ($All('.block').length === 1) { return; } GM_getValue = utils.defaultedGet({ /** @type {Record<string, string>} 字符串用户id - 用户备注 */ remarks: {}, enabled: true, }, GM_getValue); /** * 模块通讯信使,承担以下通讯任务: * - remarks更新消息 */ const messager = new EventTarget(); // 注册(不可用)设置组 configs.registerConfig('remarks', { GM_addValueChangeListener, label: CONST.Text.UserRemark.Settings.Label, items: [{ type: 'boolean', label: CONST.Text.UserRemark.Settings.Enabled, caption: CONST.Text.UserRemark.Settings.EnabledCaption, key: 'enabled', reload: true, get() { return GM_getValue('enabled'); }, set(val) { return GM_setValue('enabled', val); }, }], }); // 实际功能函数,只有启用备注功能时才运行 const pool_funcs = { review: { checkers: { type: 'path', value: '/modules/article/reviewshow.php' }, async func() { /** @type {review} */ const review = await require('review', true); const FloorManager = review.FloorManager; const floors = FloorManager.floors; // 显示用户备注 floors.forEach(floor => { addRemarkButton(floor); displayRemark(floor); }); $AEL(review.messager, 'update', e => { e.detail.floors.forEach(floor => { addRemarkButton(floor); displayRemark(floor); }); }); // 随用户备注更新显示 $AEL(messager, 'change', e => { /** @type { {id: number, remark: string} } */ const { id, remark } = e.detail; floors.filter(floor => floor.data.user.id === id).forEach(floor => { review.FloorManager.transformer.updateLine( floor, 'remark', getRemarkText(floor.data.user.id) ); }); }); /** @typedef {typeof review._types.Floor} Floor */ /** * 为评论楼层添加用户备注按钮 * @param {Floor} floor */ function addRemarkButton(floor) { review.FloorManager.transformer.addUserButton(floor, { id: 'remark', label: CONST.Text.UserRemark.RemarkUser, index: 1, callback() { promptRemark({ id: floor.data.user.id, name: floor.data.user.name }); } }); } /** * 为评论楼层的用户展示备注 * @param {Floor} floor */ function displayRemark(floor) { review.FloorManager.transformer.addUserLine(floor, { id: 'remark', line: getRemarkText(floor.data.user.id), base: 'type', position: 'before', }); } } }, userpage: { checkers: { type: 'path', value: '/userpage.php' }, async func() { /** @type {userpage} */ const userpage = await require('userpage', true); const page = userpage.PageManager.page; // 设置备注按钮 userpage.PageManager.transformer.addUserButton( page, { id: 'remark', label: CONST.Text.UserRemark.RemarkUser, index: 1, callback() { promptRemark({ id: page.data.user.id, name: page.data.user.name }); } } ); // 显示备注 userpage.PageManager.transformer.addUserLine( page, { id: 'remark', line: getRemarkText(page.data.user.id), index: 1, } ); // 随用户备注更新显示 $AEL(messager, 'change', e => { /** @type { {id: number, remark: string} } */ const { id, remark } = e.detail; userpage.PageManager.transformer.updateLine( page, 'remark', getRemarkText(page.data.user.id) ); }); } } }; if (GM_getValue('enabled')) { const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { GM_setValue, GM_getValue }); await promise; } /** * 弹窗提示用户对指定用户设置备注 * @param {Object} user - 用户信息 * @param {number} user.id - 用户id * @param {string} [user.name] - 用户名 */ function promptRemark({ id, name = null }) { Quasar.Dialog.create({ title: CONST.Text.UserRemark.Prompt.Title, message: replaceText( CONST.Text.UserRemark.Prompt.Message, { '{Name}': name ?? id.toString() } ), prompt: { model: getRemark(id) ?? name ?? id, type: 'text', color: 'primary', }, ok: { label: CONST.Text.UserRemark.Prompt.Ok, color: 'primary', }, cancel: { label: CONST.Text.UserRemark.Prompt.Cancel, color: 'secondary', }, }).onOk(remark => { setRemark(id, remark); Quasar.Notify.create({ type: 'success', message: CONST.Text.UserRemark.Prompt.Saved, caption: remark, group: 'remark.remark-saved', }); }); } /** * 获取对用户的备注 * @param {number} id - 用户id */ function getRemark(id) { const str_id = id.toString(); const remarks = GM_getValue('remarks'); return remarks.hasOwnProperty(str_id) ? remarks[str_id] : null; } /** * 设置用户的备注 * @param {number} id - 用户id * @param {string} remark - 备注内容 */ function setRemark(id, remark) { const str_id = id.toString(); const remarks = GM_getValue('remarks'); if (remark) { remarks[str_id] = remark; } else { delete remarks[str_id]; } GM_setValue('remarks', remarks); messager.dispatchEvent(new CustomEvent('change', { detail: { id, remark } })); } /** * 获取用户备注在UI中显示的文本 * 形如: "用户备注: 备注内容" / "未设置用户备注" */ function getRemarkText(id) { const remark = getRemark(id); return remark ? replaceText( CONST.Text.UserRemark.RemarkDisplay, { '{Remark}': remark } ) : CONST.Text.UserRemark.RemarkNotSet; } return { get remarks() { return GM_getValue('remarks') }, getRemark, setRemark, } } }, userreview: { desc: '查看用户书评', checkers: [{ // 书评 type: 'path', value: '/modules/article/reviewshow.php' }, { // 用户主页 type: 'path', value: '/userpage.php' }], dependencies: ['debugging', 'utils', 'configs'], params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { /** @type {utils} */ const utils = require('utils'); /** @type {configs} */ const configs = require('configs'); GM_getValue = utils.defaultedGet({ enabled: true, }, GM_getValue); // 如果是发评论返回的提示页面,不继续运行 if ($All('.block').length === 1) { return; } // 实际功能函数,只有启用备注功能时才运行 const pool_funcs = { review: { checkers: { type: 'path', value: '/modules/article/reviewshow.php' }, async func() { /** @type {review} */ const review = await require('review', true); const FloorManager = review.FloorManager; const floors = FloorManager.floors; // 显示用户备注 floors.forEach(floor => { addReviewButton(floor); }); $AEL(review.messager, 'update', e => { e.detail.floors.forEach(floor => { addReviewButton(floor); }); }); /** @typedef {typeof review._types.Floor} Floor */ /** * * @param {Floor} floor */ function addReviewButton(floor) { review.FloorManager.transformer.addUserButton(floor, { id: 'user_review', label: CONST.Text.UserReview.CheckUserReviews, index: 2, element: $$CrE({ tagName: 'a', attrs: { href: `https://${ location.host }/modules/article/reviewslist.php?keyword=${ floor.data.user.id }`, target: '_blank', }, props: { innerText: CONST.Text.UserReview.CheckUserReviews, }, }), }); } } }, userpage: { checkers: { type: 'path', value: '/userpage.php' }, async func() { /** @type {userpage} */ const userpage = await require('userpage', true); const page = userpage.PageManager.page; // 设置备注按钮 const uid = parseInt(new URLSearchParams(location.search).get('uid'), 10); userpage.PageManager.transformer.addUserButton( page, { id: 'review', index: 2, element: $$CrE({ tagName: 'a', attrs: { href: `https://${ location.host }/modules/article/reviewslist.php?keyword=${ uid }`, target: '_blank', }, props: { innerText: CONST.Text.UserReview.CheckUserReviews, }, }), } ); } }, }; if (GM_getValue('enabled')) { const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { GM_setValue, GM_getValue }); await promise; } } }, metacopy: { desc: '在小说信息页提供复制小说元标签的功能', checkers: [{ type: 'regpath', value: /\/book\/\d+\.htm/ }, { type: 'path', value: '/modules/article/articleinfo.php' }], detectDom: '.main.m_foot', func() { const tds = [...$All('#content > div:first-child > table:first-child > tbody > tr:last-child > td')]; tds.forEach(td => addCopyButton(td)); /** * @param {HTMLTableCellElement} td */ function addCopyButton(td) { const [key, val] = td.innerText.trim().split(':'); td.insertAdjacentElement('beforeend', $$CrE({ tagName: 'span', props: { innerText: CONST.Text.MetaCopy.CopyButton, }, styles: { color: 'var(--q-primary)', cursor: 'pointer', paddingLeft: '0.5em', }, listeners: [['click', e => { GM_setClipboard(val, 'text/plain'); Quasar.Notify.create({ type: 'success', message: CONST.Text.MetaCopy.Copied, caption: val, group: 'metacopy.copied', }); }]] })); } }, }, bookcase: { desc: '书架相关功能', checkers: [{ type: 'path', value: '/modules/article/bookcase.php' }, { type: 'path', value: '/modules/article/addbookcase.php' }], dependencies: ['utils'], params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], /** @typedef {Awaited<ReturnType<typeof functions.darkmode.func>>} darkmode */ async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { /** @type {utils} */ const utils = require('utils'); /** * 通信信使,通过派发CustomEvent传递消息,目前有以下事件: * - switch * 当用户切换页面上展示的书架时派发,如从默认书架切换至第一组书架 * - classid {number} * - old_form {HTMLFormElement} - 切换前显示的form元素 * - new_form {HTMLFormElement} - 切换后显示的form元素 * - update * 当书架刷新完成时派发,可以是用户主动刷新书架/执行某些书架修改后自动刷新等 * - classid {number} * - old_form {HTMLFormElement} - 数据更新前旧的form元素 * - new_form {HTMLFormElement} - 数据更新后新的form元素 * - rename * 当用户重命名书架时派发 * - classid {number} * - old_name {string} * - new_name {string} */ const messager = new EventTarget(); const pool_funcs = { collector: { desc: '多书架整合', checkers: { type: 'path', value: '/modules/article/bookcase.php' }, /** @typedef {Awaited<ReturnType<typeof pool_funcs.collector.func>>} collector */ async func() { // 获取所有书架页面 Quasar.Loading.show({ message: CONST.Text.Bookcase.Collector.FetchingBookcases }); let page_classid = parseInt(new URLSearchParams(location.search).get('classid') ?? '0', 10); const forms = await Promise.all([0, 1, 2, 3, 4, 5].map(async classid => { return classid === page_classid ? await detectDom('#checkform') : await fetchBookcase(classid); })); // 切换书架功能 Quasar.Loading.show({ message: CONST.Text.Bookcase.Collector.ArrangingBookcases }); /** @type {(classid: number) => void} */ const switchBookcase = classid => { // 切换form const cur_form = $('#checkform'); const form = forms[classid]; cur_form.after(form); cur_form.remove(); // 切换classid并更新到url page_classid = classid; const new_url = new URL(location.href); new_url.searchParams.set('classid', classid.toString()); history.replaceState(null, '', new_url.href); // 广播切换事件 messager.dispatchEvent(new CustomEvent('switch', { detail: { old_form: cur_form, new_form: form, classid, } })); }; /** @type {(form: HTMLFormElement, classid: number) => void} */ const connectSwitcher = (form, classid) => $AEL($(form, 'select[name="classlist"]'), 'change', e => { e.stopImmediatePropagation(); const select = e.target; const new_classid = parseInt(select.value, 10); select.value = classid.toString(); switchBookcase(new_classid); }, { capture: true }); applyToAllForms(connectSwitcher); // 页面内更新书架功能 /** @type {([classid]: number, [new_form]: HTMLFormElement) => Promise<void>} */ const updateBookcase = async (classid=null, new_form=null) => { Quasar.Loading.show({ message: CONST.Text.Bookcase.Collector.UpdatingBookcase }); // 如果不提供classid,则更新所有书架 if (classid === null) { await Promise.all(forms.map(async (form, classid) => updateBookcase(classid))); return; } // 获取新书架 const form = forms[classid]; new_form = new_form ?? await fetchBookcase(classid); forms[classid] = new_form; if (document.body.contains(form)) { form.after(new_form); form.remove(); } // 广播更新事件 messager.dispatchEvent(new CustomEvent('update', { detail: { old_form: form, new_form: new_form, classid, } })); Quasar.Loading.hide(); }; const convertActionsInpage = (form, classid) => { // 表单提交改为ajax提交 $AEL(form, 'submit', async e => { const form = e.target; // 记录当前操作的名称 const action_select = $(form, '#newclassid'); const action_val = action_select.value; const action_name = [...$All(action_select, 'option')] .find(option => option.value === action_val).innerText; // 提交时,阻止默认表单提交 e.preventDefault(); // 接管文库页面自带的submit钩子 e.stopImmediatePropagation(); const orig_checker = form.onsubmit; if (!await checkSubmit()) { return; } // ajax提交表单 Quasar.Loading.show({ message: CONST.Text.Bookcase.Collector.SubmitingChange }); const formdata = new FormData(form); const doc = await utils.requestDocument({ method: 'POST', url: `/modules/article/bookcase.php?classid=${page_classid}&ajax_gets=jieqi_contents`, data: utils.serializeFormData(formdata), headers: { 'content-type': 'application/x-www-form-urlencoded', 'referrer': location.href, }, }); const new_form = $(doc, '#checkform'); Quasar.Loading.hide(); // 更新书架 await Promise.all([ // 更新当前书架 updateBookcase(classid, new_form), // 如果有,更新相关书架 formdata.get('newclassid') ? updateBookcase(parseInt(formdata.get('newclassid'))) : Promise.resolve() ]); // 提示完成 Quasar.Notify.create({ type: 'success', message: replaceText( CONST.Text.Bookcase.Collector.ActionFinished, { '{ActionName}': action_name } ), group: 'bookcase.moved' }); }, { capture: true }); // 移除书籍按钮改为ajax提交 [...$All(form, 'tbody > tr > td:last-child > a')].forEach(a => $AEL(a, 'click', async e => { e.preventDefault(); const bid = parseInt(new URLSearchParams(a.closest('tr').children[1].querySelector('a').search).get('bid'), 10); const bookname = a.closest('tr').children[1].querySelector('a').innerText.trim(); if (!await confirmRemove(bookname)) { return; } const doc = await utils.requestDocument({ method: 'GET', url: `/modules/article/bookcase.php?classid=${classid}&ajax_gets=jieqi_contents&ajax_request=${Date.now()}&delid=${bid}`, }); const new_form = $(doc, '#checkform'); updateBookcase(classid, new_form); Quasar.Notify.create({ type: 'success', message: CONST.Text.Bookcase.Collector.Removed, caption: bookname, group: 'bookcase.book-removed', }); })); /** * 功能和文库自身的window.check_confirm一模一样,用于表单提交前检查和操作确认,但是用quasar提示框重写的 * @returns {Promise<boolean>} */ async function checkSubmit() { const form = $('#checkform'); // 检查是否未选中任何书籍 /** @type {string[]} 被选择的书名 */ const checked_books = [...$All(form, 'input[name="checkid[]"]')] .filter(check => check.checked) .map(check => check.closest('tr').children[1].querySelector('a').innerText.trim()); if (!checked_books.length) { Quasar.Notify.create({ type: 'error', message: CONST.Text.Bookcase.Collector.NoBooksSelected }); return false; } // 如果正在移除书籍,先进行确认 // 这里的 == 非全等号写法是在和文库自带函数代码保持一致,实际上value值应为'-1' if ($(form, '#newclassid').value == -1) { const book_names = checked_books.join('、'); return await confirmRemove(book_names); } else { return true; } } }; applyToAllForms(convertActionsInpage); // 侧边栏按钮 require('sidepanel', true).then( /** @param {sidepanel} sidepanel */ sidepanel => sidepanel.registerButton({ id: 'bookcase.refresh', icon: 'sync', label: CONST.Text.Bookcase.Collector.RefreshBookcase, index: 2, async callback() { await updateBookcase(); Quasar.Notify.create({ type: 'success', message: CONST.Text.Bookcase.Collector.Refreshed, group: 'bookcase.bookcase-refreshed', }); }, }) ); Quasar.Loading.hide(); /** * 询问用户是否要将某一书籍移出书架 * @param {string} bookname * @returns {Promise<boolean>} */ function confirmRemove(bookname) { const { promise, resolve } = Promise.withResolvers(); const ConfirmRemove = CONST.Text.Bookcase.Collector.Dialog.ConfirmRemove; Quasar.Dialog.create({ message: replaceText( ConfirmRemove.Message, { '{Name}': bookname } ), title: ConfirmRemove.Title, ok: { label: ConfirmRemove.ok, color: 'primary', }, cancel: { label: ConfirmRemove.cancel, color: 'secondary', }, }).onOk(() => resolve(true)).onCancel(() => resolve(false)); return promise; } /** * 网络请求获取指定书架form元素 * @param {number} classid * @returns {Promise<HTMLFormElement>} */ async function fetchBookcase(classid) { const doc = await utils.requestDocument({ method: 'GET', url: `/modules/article/bookcase.php?classid=${classid}&ajax_gets=jieqi_contents&ajax_request=${Date.now()}`, }); return $(doc, '#checkform'); } /** * 将提供的方法对所有书架form元素执行一次,包括现有的form、未来更新创建的新form等全部form * @param {(form: HTMLFormElement, classid: number) => any} func */ function applyToAllForms(func) { forms.forEach((form, classid) => func(form, classid)); $AEL(messager, 'update', e => func(e.detail.new_form, e.detail.classid)); } return { // 数据 forms, get classid() { return page_classid; }, set classid(classid) { page_classid = classid; }, // 功能 switchBookcase, updateBookcase, // 底层-适合内部使用 connectSwitcher, convertActionsInpage, // 底层-适合外部使用 fetchBookcase, applyToAllForms, } } }, naming: { desc: '书架自命名', dependencies: 'collector', params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], checkers: { type: 'path', value: '/modules/article/bookcase.php' }, async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { /** @type {collector} */ const collector = pool.require('collector'); const default_names = [ '默认书架', '第1组书架', '第2组书架', '第3组书架', '第4组书架', '第5组书架', ]; GM_getValue = utils.defaultedGet({ names: default_names, }, GM_getValue); // 当储存的names名称数据变化时,派发rename事件 GM_addValueChangeListener('names', (key, old_val, new_val, remote) => { for (let classid = 0; classid < 6; classid++) { const [old_name, new_name] = [ old_val?.[classid] ?? default_names[classid], new_val[classid] ]; old_name !== new_name && messager.dispatchEvent(new CustomEvent('rename', { detail: { old_name, new_name, classid, } })); } }); // 重命名按钮 collector.applyToAllForms((form, classid) => { const select = $(form, 'select[name="classlist"]'); const button = $$CrE({ tagName: 'span', props: { innerText: CONST.Text.Bookcase.Naming.Rename, }, styles: { border: '1px solid', padding: '3px', cursor: 'pointer', marginLeft: '0.5em', }, listeners: [['click', async e => { const name = await promptNewName(classid); name !== null && saveName(classid, name); }]] }); const icon = $$CrE({ tagName: 'i', classes: 'material-icons', props: { innerText: 'drive_file_rename_outline' }, styles: { verticalAlign: 'text-bottom', } }); button.insertAdjacentElement('afterbegin', icon); select.after(button); }); // 对每个书架应用用户设定的名称 collector.applyToAllForms((form, classid) => { const names = GM_getValue('names'); [...$All(form, 'select[name="classlist"] > option')].forEach((option, op_classid) => { option.innerText = names[op_classid]; }); [...$All(form, '#newclassid > option')].forEach(option => { const op_classid = parseInt(option.value, 10); if (op_classid >= 0) { option.innerText = replaceText( CONST.Text.Bookcase.Naming.MoveTo, { '{Name}': names[op_classid] } ); } }); }) // 重命名发生时修改GUI中的名称 $AEL(messager, 'rename', e => { collector.forms.forEach((form, classid) => { const switch_option = $(form, `select[name="classlist"] > option[value="${e.detail.classid}"]`); switch_option.innerText = e.detail.new_name; const move_option = $(form, `#newclassid > option[value="${e.detail.classid}"]`); move_option.innerText = replaceText( CONST.Text.Bookcase.Naming.MoveTo, { '{Name}': e.detail.new_name } ); }); }); /** * 向用户弹窗输入新的书架名字 * @param {number} classid * @returns {Promise<string | null>} 新名字,或者null(当用户点击取消时) */ function promptNewName(classid) { const { promise, resolve } = Promise.withResolvers(); const Naming = CONST.Text.Bookcase.Naming; const PromptNewName = Naming.Dialog.PromptNewName; const old_name = GM_getValue('names')[classid] ?? replaceText( Naming.DefaultName, { '{ClassID}': classid.toString() } ); Quasar.Dialog.create({ message: replaceText( PromptNewName.Message, { '{OldName}': old_name } ), title: PromptNewName.Title, prompt: { model: old_name, type: 'text', color: 'primary', }, ok: { label: PromptNewName.Ok, color: 'primary', }, cancel: { label: PromptNewName.Cancel, color: 'secondary' } }).onOk(new_name => resolve(new_name)).onCancel(() => resolve(null)); return promise; } function saveName(classid, name) { // 保存名称 const names = GM_getValue('names'); names[classid] = name; GM_setValue('names', names); } } }, addpagejump: { desc: '在“成功加入书架!”页面添加跳转到书架的按钮', checkers: { type: 'path', value: '/modules/article/addbookcase.php', }, detectDom: '.blocknote', func() { const close_btn = $('a[href="javascript:window.close()"]'); const container = close_btn.parentElement; container.insertAdjacentText('afterbegin', ' '); container.insertAdjacentText('afterbegin', ']'); container.insertAdjacentElement('afterbegin', $$CrE({ tagName: 'a', attrs: { href: `/modules/article/bookcase.php`, }, props: { innerText: CONST.Text.Bookcase.AddpageJump.GotoBookcase }, })); container.insertAdjacentText('afterbegin', '['); } } }; const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { GM_setValue, GM_getValue, GM_addValueChangeListener }); await promise; }, }, readlater: { desc: '稍后再读', dependencies: ['utils', 'debugging'], params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], /** @typedef {Awaited<ReturnType<typeof functions.darkmode.func>>} darkmode */ async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { /** @type {utils} */ const utils = require('utils'); /** @type {debugging} */ const debugging = require('debugging'); GM_getValue = utils.defaultedGet({ /** @type {Book[]} */ list: [], }, GM_getValue); /** * @typedef {Object} Book * @property {number} aid * @property {string} name * @property {string} cover */ const pool_funcs = { core: { // 这里不用让FunctionLoader包装子存储,直接将list存储在readlater的全局作用域中即可 /** @typedef {Awaited<ReturnType<typeof pool_funcs.core.func>>} core */ func() { // 内容更改监听器 /** @type {((val: Book[]) => any)[]} */ const listeners = []; GM_addValueChangeListener('list', (key, old_val, new_val, remote) => { listeners.forEach(l => debugging.callWithErrorHandling(l, null, [new_val])); }); /** * 将书籍添加到稍后再读列表 * @param {Book} book * @returns {boolean} 添加成功,还是已经在稍后再读中 */ function add(book) { /** @type {Book[]} */ const list = GM_getValue('list'); if (list.some(b => b.aid === book.aid)) { return false; } list.push(book); GM_setValue('list', list); return true; } /** * 从稍后再读中移除一本书 * @param {number} aid * @returns {Book | null} 如果移除成功,返回这本书;如果指定书不存在,返回null */ function remove(aid) { /** @type {Book[]} */ const list = GM_getValue('list'); const index = list.findIndex(b => b.aid === aid); if (index < 0) { return null; } const book = list.splice(index, 1)[0]; GM_setValue('list', list); return book; } /** * 添加稍后列表值改变监听器 * @param {(val: Book[]) => any} listener */ function onChange(listener) { listeners.push(listener); } return { /** @type {Book[]} */ get list() { return GM_getValue('list'); }, set list(val) { return GM_setValue('list', val); }, add, remove, onChange, }; } }, bookpage: { desc: '书籍信息页添加稍后再读按钮', checkers: [{ type: 'regpath', value: /\/book\/\d+\.htm/ }, { type: 'path', value: '/modules/article/articleinfo.php' }], dependencies: 'core', /** @typedef {Awaited<ReturnType<typeof pool_funcs.bookpage.func>>} bookpage */ async func() { /** @type {sidepanel} */ const sidepanel = await require('sidepanel', true); /** @type {core} */ const core = pool.require('core'); sidepanel.registerButton({ id: 'readlater.add', icon: 'watch_later', label: CONST.Text.ReadLater.Add, index: 3, callback() { const aid = parseInt(new URLSearchParams(location.search).get('id') ?? location.pathname.match(/\/book\/(\d+)\.htm/)[1], 10); const name = $('#content > div:first-child > table:first-child > tbody > tr:first-child > td > table span > b').innerText.trim(); const cover = $('#content > div:first-child > table:nth-of-type(2) img').src; const success = core.add({ aid, name, cover }); const ReadLater = CONST.Text.ReadLater; Quasar.Notify.create({ type: 'success', message: ReadLater.Added, caption: replaceText( success ? ReadLater.AddSuccess : ReadLater.AddDuplicate, { '{Name}': name } ), icon: success ? 'done' : 'question_mark', group: 'readlater.added' }); } }); } }, indexpage: { desc: '主页展示稍后再读', checkers: [{ type: 'path', value: '/index.php' }, { type: 'path', value: '/' }], detectDom: '.main.m_foot', dependencies: ['core'], async func() { /** @type {core} */ const core = pool.require('core'); // 创建稍后再读列表 const container = $$CrE({ tagName: 'div', classes: 'main' }); container.innerHTML = ` <div class="block"> <div class="blocktitle">${ CONST.Text.ReadLater.Title }</div> <div class="blockcontent"> <div style="height:155px;"> </div> </div> </div> `; $('.main.m_foot').previousElementSibling.previousElementSibling.before(container); const books_container = $(container, '.blockcontent > div'); // 创建Sortable const sortable = new Sortable(books_container, { onUpdate(e) { const aidlist = sortable.toArray(); core.list = aidlist.map(aid => core.list.find(book => book.aid === parseInt(aid, 10))); }, }); // 创建列表内容 refreshList(); // 当列表更改时,重建列表 core.onChange(list => refreshList(list)); /** * 清空稍后再读列表并重建 * @param {Book[]} [list] */ function refreshList(list) { list = list ?? core.list; // 首先清空已有内容 [...books_container.children].forEach(elm => elm.remove()); // 重建 if (list.length) { // 如果稍后再读不为空,则为前十本书创建元素 // 之所以是前十本,是因为文库的这个列表只有展示十本的空间 list.filter((b, i) => i < 10).forEach(book => { const book_container = $$CrE({ tagName: 'div', attrs: { style: 'float: left;text-align:center;width: 95px; height:155px;overflow:hidden;', 'data-id': book.aid.toString(), }, styles: { position: 'relative' }, classes: 'plus-readlater-book', }); book_container.innerHTML = ` <a href="/book/${ book.aid }.htm" target="_blank"> <img src="${ book.cover }" border="0" width="90" height="127"></a> <br> <a href="/book/${ book.aid }.htm" target="_blank">${ book.name }</a> `; book_container.append($$CrE({ tagName: 'div', props: { innerHTML: `<i class="material-icons">close</i>`, }, classes: ['plus-remove-readlater'], listeners: [[ 'click', e => core.remove(book.aid) ]] })); addStyle(` .plus-remove-readlater { position: absolute; right: 0; top: 0; font-size: 1.5em; color: #0d548b; border: 1px dashed #0d548b; padding: 0.1em; cursor: pointer; background: rgba(255, 255, 255, 0.5); display: none; } :is(body.mobile, .plus-readlater-book:hover) .plus-remove-readlater { display: block; } .plus-remove-readlater:hover { background: rgba(255, 255, 255, 0.8); } `, 'readlater-style'); utils.setTip($(book_container, 'a:first-child'), book.name); books_container.append(book_container); }); } else { // 如果稍后再读为空,展示提示 books_container.append($$CrE({ tagName: 'div', props: { innerText: CONST.Text.ReadLater.EmptyListPlaceholder }, classes: 'text-grey-7', styles: { width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '1.5em', } })); } } } } }; const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { GM_setValue, GM_getValue, GM_addValueChangeListener }); await promise; return { /** @type {core} */ core: pool.require('core'), /** 用于导出JSDoc类型,无实际作用 */ _types: { /** @type {Book} */ Book: {}, } }; }, }, blockfolding: { desc: '主页板块折叠', checkers: [{ type: 'path', value: '/' }, { type: 'path', value: '/index.php' }], dependencies: ['utils'], params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], func(GM_setValue, GM_getValue, GM_addValueChangeListener) { /** @type {utils} */ const utils = require('utils'); /** * 记录了折叠状态但不再出现在文档中的板块的记录 * @typedef {{title: string, count: number}} DisappearRecord */ GM_getValue = utils.defaultedGet({ /** @type {string[]} 折叠板块的标题列表 */ folds: [], /** @type {DisappearRecord[]} 在folds中但在页面中未出现的板块列表 */ unused: [], }, GM_getValue); // 应用折叠到文档 detectDom({ selector: '.block', /** @param {HTMLDivElement} block */ callback(block) { block.matches('[class*="q-"]:not(body) *') || initBlock(block); } }); // 当存储改变时,同步改变文档折叠状态 GM_addValueChangeListener('folds', (key, old_val, new_val, remote) => { [...$All('.block')].forEach(block => applyFoldStatus(block)); }); // 清理已消失的板块的折叠状态 $AEL(window, 'load', e => { const folds = GM_getValue('folds'); const titles = [...$All('.block')].filter(block => !block.matches('[class*="q-"]:not(body) *')).map(block => getTitle(block)); let modified = false; // 记录在folds中、但最终未出现在文档中的板块,记录到unused中 // 当在unused中记录次数达到一定值时,将其从folds和unused中移除 folds.filter(t => !titles.includes(t)).forEach(title => { /** @type {DisappearRecord[]} */ const unused = GM_getValue('unused'); const record = unused.find(r => r.title === title) ?? { title, count: 0 }; record.count++; if (record.count >= CONST.Internal.RemoveBlockFoldingCount) { // 达到清除标准,从unused和folds中移除此板块和记录 modified = true; folds.splice(folds.indexOf(record.title), 1); unused.includes(record) && unused.splice(unused.indexOf(record), 1); } else { // 未达到清除标准,仅修改记录次数 !unused.includes(record) && unused.push(record); } GM_setValue('unused', unused); }); // 在unused中有记录,但本次观察到出现的板块,清除在unused中的记录 /** @type {DisappearRecord[]} */ const unused = GM_getValue('unused'); unused.filter(r => titles.includes(r.title)).forEach(record => unused.splice(unused.indexOf(record), 1)); modified && GM_setValue('folds', folds); }); // 样式 addStyle(` .plus-folded .blockcontent { display: none; } .blocktitle .foldbtn { display: inline; } .plus-folded .blocktitle .foldbtn { display: none; } .blocktitle .unfoldbtn { display: none; } .plus-folded .blocktitle .unfoldbtn { display: inline; } .foldbtn-group { float: right; height: 100%; display: flex; flex-direction: row; align-items: center; cursor: pointer; margin-right: 10px; width: 0; position: relative; overflow: visible; } .foldbtn-group * { position: absolute; right: 0; text-align: right; white-space: nowrap; } `); /** * 初始化指定板块,添加折叠/展开按钮,一次性应用存储的折叠/展开状态 * @param {HTMLDivElement} block */ function initBlock(block) { // 添加折叠/展开按钮 const button = $$CrE({ tagName: 'span', classes: 'foldbtn-group' }); button.append( $$CrE({ tagName: 'span', props: { innerText: CONST.Text.BlockFolding.Fold, }, classes: 'foldbtn', listeners: [['click', e => setFold(block, true)]] }), $$CrE({ tagName: 'span', props: { innerText: CONST.Text.BlockFolding.UnFold, }, classes: 'unfoldbtn', listeners: [['click', e => setFold(block, false)]] }), ); $(block, '.blocktitle').append(button); // 应用存储的折叠/展开状态 applyFoldStatus(block); } /** * 将存储的折叠状态应用到指定的板块DOM中 * @param {HTMLDivElement} block */ function applyFoldStatus(block) { const title = getTitle(block); const folded = GM_getValue('folds').includes(title); folded ? fold(block) : unfold(block); } /** * 将一个板块DOM置于折叠状态 * @param {HTMLDivElement} block */ function fold(block) { block.classList.add('plus-folded'); } /** * 将一个板块DOM置于展开(非折叠)状态 * @param {HTMLDivElement} block */ function unfold(block) { block.classList.remove('plus-folded'); } /** * 设置一个板块的折叠/展开状态到存储 * @param {HTMLDivElement} block * @param {boolean} fold */ function setFold(block, fold) { const title = getTitle(block); const folds = GM_getValue('folds'); fold ? (folds.includes(title) || folds.push(title)) : (folds.includes(title) && folds.splice(folds.indexOf(title), 1)); GM_setValue('folds', folds); } function getTitle(block) { const blocktitle = $(block, '.blocktitle').cloneNode(true); $(blocktitle, '.foldbtn-group')?.remove(); return blocktitle.innerText.trim(); } }, }, announcements: { desc: '在首页等位置插入脚本公告信息等', checkers: [{ type: 'path', value: '/' },{ type: 'path', value: '/index.php' }], detectDom: '.main.m_foot', async func() { const block = $('#centers > .block:first-child'); const blockcontent = $(block, '.blockcontent'); blockcontent.append( $CrE('br'), $$CrE({ tagName: 'span', props: { innerText: CONST.Text.Announcements.Running, }, styles: { color: '#6f9ff1' }, } )); } }, downloader: { desc: '多功能下载器', dependencies: ['utils', 'api'], checkers: [{ type: 'regpath', value: /\/book\/\d+\.htm/ }, { type: 'path', value: '/modules/article/articleinfo.php' }, { type: 'regpath', value: /\/novel\/\d+\/\d+\/index.html?/ }, { type: 'path', value: '/modules/article/reader.php' }], /** @typedef {Awaited<ReturnType<typeof functions.downloader.func>>} downloader */ async func() { /** @type {utils} */ const utils = require('utils'); /** @type {api} */ const api = require('api'); const pool_funcs = { core: { desc: '下载器核心:下载器界面、功能', /** @typedef {Awaited<ReturnType<typeof pool_funcs.core.func>>} core */ async func() { const Options = CONST.Text.Downloader.Options; const DownloadOptions = { format: { type: 'select', label: Options.Format.Title, options: [{ label: Options.Format.txt, value: 'txt', }, { label: Options.Format.epub, value: 'epub', }, { label: Options.Format.image, value: 'image', }], default: 'epub', }, encoding: { type: 'select', label: Options.Encoding.Title, caption: Options.Encoding.Caption, options: [{ label: Options.Encoding.gbk, value: 'gbk', }, { label: Options.Encoding.utf8, value: 'utf-8', }], default: 'utf-8' }, }; /** * @typedef {Object} NovelInfo * @property {string} intro * @property {NovelMeta} meta * @property {NovelVolume[]} volumes * @property {string} cover */ /** * @typedef {Object} NovelMeta * @property {{value: string, aid: number}} Title * @property {string} Author * @property {number} DayHitsCount * @property {number} TotalHitsCount * @property {number} PushCount * @property {number} FavCount * @property {{value: string, sid: number}} PressId * @property {string} BookStatus * @property {number} BookLength * @property {string} LastUpdate * @property {string} Tags * @property {{value: string, cid: number}} LatestSection */ /** * @typedef {Object} NovelVolume * @property {string} name * @property {number} vid * @property {NovelChapter[]} chapters */ /** * @typedef {Object} NovelChapter * @property {string} name * @property {number} cid */ /** * @callback DownloadCallback * @param {Object} detail * @param {number} detail.aid * @param {NovelInfo} detail.info * @param {Record<string, any>} detail.options * @param {number[]} detail.chapters * @returns {any} */ const pool_funcs = { gui: { /** @typedef {Awaited<ReturnType<typeof pool_funcs.gui.func>>} gui */ async func() { const container = $CrE('div'); const UI = CONST.Text.Downloader.UI; container.innerHTML = ` <q-dialog v-model="visible" full-width full-height class="plus-downloader"> <q-layout container view="hHh lpR fFf"> <q-header bordered class="bg-primary text-white" height-hint="98"> <q-toolbar> <q-toolbar-title> <q-icon name="book" class="q-px-sm"></q-icon> ${ CONST.Text.Downloader.Title } </q-toolbar-title> <q-btn icon="close" v-close-popup flat></q-btn> </q-toolbar> </q-header> <q-page-container> <q-page> <q-card square class="downloader-container q-pa-md"> <!-- 小屏幕上下拆分,大屏幕左右拆分 --> <div :class="{ row: horizontal }" class="text-body2 scroll" style="height: 100%;"> <!-- 书籍信息和下载选项 --> <div class="col"> <!-- 上半部分 书籍信息 --> <div class="row"> <!-- 左侧封面图 --> <div class="col-3 q-pa-md"> <q-skeleton v-if="loading" type="rect" width="100%" height="15em"></q-skeleton> <q-img v-else :src="info.cover"></q-img> </div> <!-- 右侧书籍信息 --> <div class="col-9 q-pa-md"> <div v-if="loading"> <q-skeleton type="rect" class="text-h5 q-mb-md" width="10em" height="1.2em"></q-skeleton> <q-skeleton type="rect" width="100%" height="12em"></q-skeleton> </div> <div v-else> <div class="text-h5 q-mb-md">{{ info.meta.Title.value }}</div> <div class="q-my-sm"><span class="text-weight-bold">${ UI.Author }</span>{{ info.meta.Author }}</div> <div class="q-my-sm"><span class="text-weight-bold">${ UI.BookStatus }</span>{{ info.meta.BookStatus }}</div> <div class="q-my-sm"><span class="text-weight-bold">${ UI.LastUpdate }</span>{{ info.meta.LastUpdate }}</div> <div class="q-my-sm"><span class="text-weight-bold">${ UI.Tags }</span>{{ info.meta.Tags }}</div> <div class="q-my-sm"><span class="text-weight-bold">${ UI.Intro }</span>{{ info.intro }}</div> </div> </div> </div> <!-- 下半部分 下载选项 --> <div> <q-list> <q-item tag="label" v-for="(option, key) in options" class="row"> <q-item-section class="col-9"> <q-item-label>{{ option.label }}</q-item-label> <q-item-label caption v-if="option.caption">{{ option.caption }}</q-item-label> </q-item-section> <q-item-section side class="col-3"> <!-- 根据不同option类型创建不同的表单元素 --> <div v-if="option.type === 'boolean'"> <q-toggle color="primary" v-model="option_vals[key]" > </div> <div v-if="option.type === 'select'" style="width: 100%;"> <q-select :options="option.options" v-model="option_vals[key]" ></q-select> </div> <div v-if="option.type === 'string'" style="width: 100%;"> <q-input v-model="option_vals[key]" ></q-input> </div> </q-item-section> </q-item> </q-list> </div> </div> <!-- 下载内容范围选择器 --> <div class="col q-pa-md" :class="{ scroll: horizontal }" :style="{ height: horizontal ? '100%' : '' }"> <div class="text-h5 q-pb-md">${ UI.ContentSelectorTitle }</div> <q-skeleton v-if="loading" type="rect" width="100%" height="70%"></q-skeleton> <q-tree v-else :nodes="tree" node-key="id" tick-strategy="leaf" v-model:ticked="ticked" ></q-tree> </div> </div> </q-card> </q-page> </q-page-container> <q-footer bordered class="bg-dark"> <q-toolbar> <q-toolbar-title> <!-- 下载进度显示 --> <span class="text-body2"> <span v-if="downloading"> <span class="q-px-sm">${ replaceText( UI.Progress.Global, { '{Total}': '{{ progress.total }}', '{CurStep}': '{{ Math.min(progress.total, progress.finished + 1) }}', '{Name}': '{{ sub_progress.name ?? "" }}', } ) }</span> <span class="q-px-sm">${ replaceText( UI.Progress.Sub, { '{Total}': '{{ sub_progress.total }}', '{CurStep}': '{{ Math.min(sub_progress.total, sub_progress.finished + 1) }}', } ) }</span> </span> <span v-else>{{ loading ? "${ UI.Progress.Loading }" : "${ UI.Progress.Ready }" }}</span> </span> </q-toolbar-title> <q-skeleton v-if="loading" type="QBtn"></q-skeleton> <q-btn v-else icon="download" :loading="downloading" :percentage="download_percentage" label="${ UI.DownloadButton }" @click="submit" flat></q-btn> </q-toolbar> </q-footer> </q-layout> </q-dialog> `; document.body.append(container); addStyle(` .plus-downloader .downloader-container { position: absolute; width: 100%; height: 100%; } `); let instance; const app = Vue.createApp({ data() { return { visible: false, aid: 0, // 正在加载状态(加载时显示占位UI) loading: false, // 正在下载状态(下载时显示下载状态UI) downloading: false, // 是否已获取到完整api信息 api_loaded: false, // 存储api原始信息 api: { full_intro: null, full_meta: null, novel_index: null, }, // 下载选项 options: {}, // 选项数据 option_vals: {}, // 用户选择下载的内容 ticked: [], // 下载按钮回调 /** @type {DownloadCallback} */ callback: (...args) => console.log(args), // 下载进度管理器 download_manager: null, // 下载进度 progress: { finished: 0, total: 0, }, // 次级下载进度管理器 sub_manager: null, // 次级下载进度 sub_progress: { finished: 0, total: 0, name: null, } } }, computed: { /** * 是否为大屏幕,大屏幕横向布局,小屏幕纵向布局 * @type {boolean} */ horizontal() { return Quasar.Screen.gt.sm; }, /** * 从api原始信息解析为纯信息数据对象 * @type {NovelInfo} */ info() { const { full_intro, full_meta, novel_index, cover } = this.api; /** @type {NovelInfo} */ const info = {}; info.intro = full_intro; info.meta = [...$All(full_meta, 'data')].reduce((meta, data) => { const attrs = {}; // 获取主要值 const name = data.getAttribute('name'); const value = data.getAttribute('value') ?? data.firstChild.nodeValue; // 获取次要值 const cloned_data = data.cloneNode(true); cloned_data.removeAttribute('name'); cloned_data.removeAttribute('value'); const attr_names = cloned_data.getAttributeNames(); // 根据次要值是否存在决定如何合并到总meta数据对象中 if (attr_names.length) { // 次要值存在:主要值作为"value"属性值,次要值作为其他属性,整体attr对象作为一个属性合并到meta数据对象中 attrs.value = value; for (let attr_name of attr_names) { let attr_val = data.getAttribute(attr_name); attr_val = /^\d+$/.test(attr_val) ? parseInt(attr_val, 10) : attr_val; attrs[attr_name] = attr_val; } return Object.assign(meta, { [name]: attrs }); } else { // 次要值不存在,只有主要值:name: 主要值 直接作为一个属性合并到meta数据对象中 attrs[name] = value; return Object.assign(meta, attrs); } }, {}); info.volumes = [...$All(novel_index, 'volume')].map(volume => { return { name: volume.firstChild.nodeValue, vid: parseInt(volume.getAttribute('vid'), 10), chapters: [...$All(volume, 'chapter')].map(chapter => { return { name: chapter.firstChild.nodeValue, cid: parseInt(chapter.getAttribute('cid'), 10), }; }), }; }); info.cover = `http://img.wenku8.com/image/${ Math.floor(this.aid / 1000) }/${ this.aid }/${ this.aid }s.jpg`; return info; }, tree() { // 注意:QTree的节点id要求全局唯一(而不仅仅是同层级唯一),这里直接使用了 // vid和cid作为QTree的id,是因为已知vid、cid是全局唯一的。若vid、cid并非 // 全局唯一,就需要自行创建适用于QTree的id并做好与章节、分卷之间的映射 return this.api_loaded ? this.info.volumes.map( volume => ({ id: volume.vid, label: volume.name, children: volume.chapters.map(chapter => ({ id: chapter.cid, label: chapter.name, })) }) ) : []; }, download_percentage() { return (this.progress.finished / this.progress.total) * 100; }, }, watch: { // 当options改变时,重置option_vals为各option.default options: { handler(val, old_val) { this.option_vals = Object.entries(Vue.toRaw(val)).reduce( (vals, [key, option]) => Object.assign(vals, { [key]: option.options.find(o => o.value === option.default) }), {} ); }, deep: true, }, // 自动绑定下载管理器进度与当前app下载进度 download_manager: { handler(new_manager, old_manager) { if (!new_manager) { return; } const that = this; // 同步大进度 const progress = this.progress; const sync = manager => { progress.finished = manager.finished; progress.total = manager.steps; }; $AEL(new_manager, 'progress', e => sync(new_manager)); // 防止下载器在首次更新进度时还没有添加进度同步监听器,这里手动同步一次 this.download_manager && sync(this.download_manager) // 同步小进度 const sub_progress = this.sub_progress; const linkSubManager = sub_manager => { that.sub_manager = sub_manager; sub_progress.name = sub_manager.info; $AEL(sub_manager, 'progress', e => { sub_progress.finished = sub_manager.finished; sub_progress.total = sub_manager.steps; }); }; $AEL(new_manager, 'sub', e => { const sub_manager = new_manager.children[new_manager.children.length-1]; linkSubManager(sub_manager); }); // 防止下载器在首次生成子进度管理器的时候还没有添加小进度同步监听器,这里手动同步一次 if (new_manager.children.length) { const sub_manager = new_manager.children[new_manager.children.length-1]; linkSubManager(sub_manager); } // 有关大小进度:实际下载实现中,所有下载器均应按照以下标准: // - 整体下载进度分N步,称为 大步骤、大进度 // - 每个大进度内部分M步,称为 小步骤、小进度 // - 只有当一个大步骤内部的全部小步骤都完成时,这个大步骤才会完成,此时大进度++,刚刚完成的这个大步骤内部的小进度应为100% // - 大进度和小进度分别用一个ProgressManager和它的一个sub manager表示和管理 // 因此,全局只有一个大进度对应的ProgressManager,统一时刻只有一个活跃的sub manager // 故不用担心上一大步骤的下属sub manager突然更新并对sub_progress写入脏数据,因为所有之前大步骤的sub_manager都应时100%进度且不再活跃 }, immediate: true, }, // 当章节列表更新时,自动选中全部章节 tree: { handler(new_tree, old_tree) { if (!this.api_loaded) { return; } for (const volume of new_tree) { for (const chapter of volume.children) { this.ticked.push(chapter.id); } } }, immediate: true, } }, methods: { /** * 从文库服务器获取有关当前书籍的全部下载器所需信息,填充到this.api中 * 获取时将UI置为加载中状态 */ async request() { this.loading = true; const [aid, lang] = [this.aid, utils.getLanguage()]; [ this.api.full_intro, this.api.full_meta, this.api.novel_index, ] = await Promise.all([ api.getNovelFullIntro({ aid, lang }), api.getNovelFullMeta({ aid, lang }), api.getNovelIndex({ aid, lang }), ]); this.loading = false; this.api_loaded = true; }, resetProgress() { this.progress = { finished: 0, total: 0, }; this.sub_progress = { finished: 0, total: 0, name: null, }; this.download_manager = null; this.sub_manager = null; }, async submit() { const aid = this.aid; const info = structuredClone(Vue.toRaw(this.info)); const chapters = Array.from(Vue.toRaw(this.ticked)); const options = Object.entries(Vue.toRaw(this.option_vals)) .reduce((options, [key, val]) => Object.assign(options, { [key]: val.value }), {}); const callback = this.callback ?? function() {}; if (chapters.length) { this.downloading = true; this.resetProgress(); await Promise.resolve(callback({ aid, info, options, chapters })); this.downloading = false; } else { Quasar.Notify.create({ type: 'error', message: CONST.Text.Downloader.UI.NoContentSelected, group: 'downloader.core.gui.no-chapters-selected', }); } } }, mounted() { instance = this; }, }); app.use(Quasar); app.mount(container); /** * 根据提供的书籍aid,初始化并展示下载器gui * @param {number} aid * @param {DownloadCallback} [callback] */ async function show(aid, callback) { instance.aid = aid; callback && (instance.callback = callback); instance.options = DownloadOptions; instance.request(); instance.visible = true; } /** * 隐藏下载器gui */ function hide() { instance.visible = false; } return { get download_progress() { return instance.download_manager; }, set download_progress(manager) { instance.download_manager = manager; }, show, hide, }; } }, downloader: { /** @typedef {Awaited<ReturnType<typeof pool_funcs.downloader.func>>} downloader */ async func() { // 每种下载格式独立实现一个子功能函数,提供download接口 /** * 标准下载接口 * @callback DownloadFunction * @param {Object} options * @param {number} options.aid - 书籍id * @param {NovelInfo} options.info - 书籍信息 * @param {number[]} options.chapters - 需要下载的章节列表 * @param {string} [options.encoding='utf-8'] - 使用的编码(如果支持) * @returns {{ blob_promise: Promise<Blob>, manager: InstanceType<typeof utils.ProgressManager>, filename: string }} */ const pool_funcs = { txt: { /** @typedef {Awaited<ReturnType<typeof pool_funcs.txt.func>>} txt */ func() { /** * 下载为txt文件 * @type {DownloadFunction} */ async function download({ aid, info, chapters, encoding='utf-8' }) { // 进度管理器 const manager = new utils.ProgressManager(3); // 下载txt主流程 const blob_promise = new Promise(async (resolve, reject) => { // 下载章节内容 const manager_content = manager.sub(chapters.length, CONST.Text.Downloader.Steps.txt.NovelContent); const lang = utils.getLanguage(); const contents = await manager.progress(Promise.all(chapters.map(async cid => await manager_content.progress(api.getNovelContent({ aid, cid, lang, })) ))); // 编码 const manager_encode = manager.sub(chapters.length, CONST.Text.Downloader.Steps.txt.EncodeText); const SupportedEncodings = ['gbk', 'big5']; const blobs = contents.map(content => { const buffer = SupportedEncodings.includes(encoding) ? $URL[encoding].encodeBuffer(content) : new TextEncoder().encode(content); const blob = new Blob([buffer], { type: 'text/plain' }); manager_encode.progress(); return blob; }); manager.progress(); // 合成zip文件 const manager_zip = manager.sub(100, CONST.Text.Downloader.Steps.txt.GenerateZIP); const zip = new JSZip(); blobs.forEach((blob, i) => { const cid = chapters[i]; const volume = info.volumes.find(v => v.chapters.some(c => c.cid === cid)); const chapter = volume.chapters.find(c => c.cid === cid); const folder = zip.folder(`${ volume.vid } - ${volume.name}`); folder.file(`${ chapter.cid } - ${ chapter.name }.txt`, blob); }); const blob = await manager.progress(zip.generateAsync( { type: 'blob' }, metadata => manager_zip.progress(null, Math.round(metadata.percent)) )); resolve(blob); }); return { blob_promise, manager, filename: `${aid} - ${info.meta.Title.value}.zip`, } } return { download }; } }, image: { /** @typedef {Awaited<ReturnType<typeof pool_funcs.image.func>>} image */ func() { /** * 下载全部插图 * @type {DownloadFunction} */ async function download({ aid, info, chapters, encoding='utf-8' }) { const manager = new utils.ProgressManager(3); // 获取与合成图片zip文件主流程 const blob_promise = new Promise(async (resolve, reject) => { // 获取全部章节,解析插图 /** * @typedef {Object} ImageChapter * @property {string[]} urls * @property {number} cid * @property {string} title */ const manager_content = manager.sub(chapters.length, CONST.Text.Downloader.Steps.image.NovelContent); const lang = utils.getLanguage(); const image_chapters = await manager.progress(Promise.all(chapters.map(async cid => { const content = await api.getNovelContent({ aid, cid, lang }); const matches = content.matchAll(/<!--image-->([^<]+?)<!--image-->/g); const urls = [...matches].map(([full, url]) => url); const volume = info.volumes.find(volume => volume.chapters.some(chapter => chapter.cid === cid)); const chapter = volume.chapters.find(chapter => chapter.cid === cid); const title = chapter.name; /** @type {ImageChapter} */ const image_chapter = { cid, title, urls }; manager_content.progress(); return image_chapter; }))); // 获取全部插图并打包为ZIP const manager_image = manager.sub(image_chapters.length, CONST.Text.Downloader.Steps.image.DownloadImage); const zip = new JSZip(); await manager.progress(Promise.all(image_chapters.map(async image_chapter => { // 没有图片的章节就不创建文件夹了 if (!image_chapter.urls.length) { return; } // 为章节创建文件夹 const foldername = `${image_chapter.cid} - ${image_chapter.title}`; const folder = zip.folder(foldername); // 添加图片到文件夹中 const num_len = image_chapter.urls.length.toString().length; await Promise.all(image_chapter.urls.map(async (url, i) => { const path = new URL(url).pathname; const ext = path.includes('.') ? path.slice(path.lastIndexOf('.') + 1) : 'jpg'; const filename = `${ utils.zfill(`${i+1}`, num_len) }.${ ext }`; const blob = await utils.requestBlob(url); folder.file(filename, blob); })); manager_image.progress(); }))); // 生成blob文件 const manager_blob = manager.sub(100, CONST.Text.Downloader.Steps.image.GenerateZIP); const blob = await manager.progress(zip.generateAsync( { type: 'blob' }, metadata => manager_blob.progress(null, Math.round(metadata.percent)) )); resolve(blob); }); return { blob_promise, manager, filename: `${aid} - ${info.meta.Title.value}.zip`, } } return { download }; } }, epub: { /** @typedef {Awaited<ReturnType<typeof pool_funcs.epub.func>>} epub */ func() { /** * @type {DownloadFunction} */ function download({ aid, info, chapters, encoding='utf-8' }) { const manager = new utils.ProgressManager(2); const blob_promise = new Promise(async (resolve, reject) => { // jEpub 实例 const epub = new jEpub(); epub.init({ i18n: 'en', title: info.meta.Title.value, author: info.meta.Author, publisher: info.meta.PressId.value, description: info.intro, tags: info.meta.Tags.split(/\s+/g) }); epub.date(new Date(info.meta.LastUpdate)); epub.notes(replaceText( CONST.Text.Downloader.Notes, { '{URL}': `https://${location.host}/book/${aid}.htm`, } )); /** * 用于记录分卷层级信息的Map * 内容为每一分卷所对应的全部章节在epub中的page的index数组 * @type {Map<NovelVolume, number[]>} */ const volume_map = new Map(); // 并发进行所有需要网络请求的工作 const manager_fetch = manager.sub(chapters.length + 1, CONST.Text.Downloader.Steps.epub.NovelContent); await manager.progress(Promise.all([ // 加载封面 (async function() { const blob = await utils.requestBlob(info.cover); epub.cover(blob); manager_fetch.progress(); }) (), // 加载章节内容 (async function() { // 先获取、整理章节内容 const epub_chapters = await Promise.all(chapters.map(async (cid, i) => { // 获取章节内容 const lang = utils.getLanguage(); const content = await api.getNovelContent({ aid, cid, lang }); let html_content = content; // 处理章节图片 const matches = [...html_content.matchAll(/<!--image-->([^<]+?)<!--image-->/g)]; const len = matches.length.toString().length; const chapter_index = utils.zfill(`${i + 1}`, chapters.length.toString().length); await Promise.all(matches.map(async ([full, url], i) => { const image_index = utils.zfill(`${i+1}`, len); const image_id = `ChapterImage-${ chapter_index }-${ image_index }`; html_content = html_content.replace(full, `<%= image[${ escJsStr(image_id) }] %>`); epub.image(await utils.requestBlob(url), image_id); })); // 整理文本内容 html_content = html_content.split(/[\r\n]+/g).map(line => `<p>${line}</p>`).join('\n'); // 整理返回epub信息 const volume = info.volumes.find(v => v.chapters.some(c => c.cid === cid)); const chapter = volume.chapters.find(c => c.cid === cid); manager_fetch.progress(); return { volume, chapter, title: chapter.name, content: html_content, }; })); // 最后再按顺序统一添加到epub // 同时记录分卷层级信息 epub_chapters.forEach((epub_chapter, index) => { // 添加章节到epub epub.add(epub_chapter.title, epub_chapter.content); // 记录分卷层级信息 const volume = epub_chapter.volume; volume_map.has(volume) || volume_map.set(volume, []); volume_map.get(volume).push(index); }); }) (), ])); // Hook epub的zip文件添加过程,以修改toc文件内部目录层级 const zip = epub._Zip; const add_file = zip.file.bind(zip); zip.file = function(path, content) { switch (path) { case 'toc.ncx': return ncx(); case 'OEBPS/table-of-contents.html': return html(); default: return add_file(...arguments); } function ncx() { // 解析为xml const xml = new DOMParser().parseFromString(content, 'application/xml'); // 按照分卷重构目录结构 volume_map.entries().forEach(([volume, indexes], volume_index) => { // 创建分卷层级的<navPoint> const first_page_src = $(xml, `#page-${indexes[0]} > content`).getAttribute('src'); const volume_nav = xml.createElement('navPoint'); volume_nav.id = `volume-${volume_index}`; volume_nav.innerHTML = ` <navLabel> <text>${ utils.htmlEncode(volume.name) }</text> </navLabel> <content src=${ escJsStr(first_page_src) }></content> `; $(xml, 'navMap').append(volume_nav); // 将该分卷所属所有章节的<navPoint>移动到分卷<navPoint>内 indexes.forEach(index => volume_nav.append($(xml, `#page-${index}`))); }); // 重新生成playOrder let playOrder = 0; const order_map = new Map(); for (const nav of $All(xml, 'navPoint')) { const src = $(nav, 'content').getAttribute('src'); order_map.has(src) || order_map.set(src, ++playOrder); nav.setAttribute('playOrder', (order_map.get(src)).toString()); } // 序列化为xml代码 let new_xml_code = new XMLSerializer().serializeToString(xml); // xml序列化会自动添加namespace信息,即xmlns="...",不符合epub规范,需要删掉 new_xml_code = new_xml_code.replaceAll(/navPoint xmlns="[^"]*"/g, 'navPoint'); // 添加到zip中 return add_file(path, new_xml_code); } function html() { // 解析为html文档 const doc = new DOMParser().parseFromString(content, 'text/html'); // 按照分卷重构目录结构 volume_map.entries().forEach(([volume, indexes], volume_index) => { const li = $$CrE({ tagName: 'li', classes: 'chaptertype-1', props: { innerHTML: volume.name }, }); const ul = $CrE('ul'); li.append(ul); $(doc, '#toc > ul').append(li); indexes.forEach(index => { const a = $(doc, `a[href="page-${index}.html"]`); const li = a.parentElement; li.classList.remove('chaptertype-1'); ul.append(li); }); }); // 序列化为html代码 const new_html_code = new XMLSerializer().serializeToString(doc); // 添加到zip中 return add_file(path, new_html_code); } } // 为epub生成blob const manager_blob = manager.sub(100, CONST.Text.Downloader.Steps.epub.GenerateEpub); const blob = await manager.progress(epub.generate( 'blob', metadata => manager_blob.progress(null, Math.round(metadata.percent)) )); resolve(blob); }); return { blob_promise, manager, filename: `${aid} - ${info.meta.Title.value}.epub`, } } return { download }; } }, }; const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { GM_setValue, GM_getValue, GM_addValueChangeListener }); await promise; /** @type {txt} */ const txt = pool.require('txt'); /** @type {image} */ const image = pool.require('image'); /** @type {epub} */ const epub = pool.require('epub'); return { txt, image, epub, }; } }, }; const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { GM_setValue, GM_getValue, GM_addValueChangeListener }); await promise; /** @type {gui} */ const gui = pool.require('gui'); /** @type {downloader} */ const downloader = pool.require('downloader'); /** * 为指定书籍展示下载器 * @param {number} aid */ function show(aid) { gui.show(aid, async ({ aid, info, chapters, options }) => { if (downloader[options.format]) { const { blob_promise, manager, filename } = await downloader[options.format].download({ aid, info, chapters, encoding: options.encoding, }); gui.download_progress = manager; const blob = await blob_promise; const url = URL.createObjectURL(blob); dl_browser(url, filename); setTimeout(() => URL.revokeObjectURL(url)); } else { console.log(aid, info, chapters, options); } }); } return { gui, downloader, show, }; } }, }; const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { GM_setValue, GM_getValue, GM_addValueChangeListener }); await promise; /** @type {core} */ const core = pool.require('core'); require('sidepanel', true).then( /** @param {sidepanel} sidepanel */ sidepanel => sidepanel.registerButton({ id: 'downloader.show', label: CONST.Text.Downloader.SideButton, icon: 'download', index: 2, async callback() { const aid = parseInt( new URLSearchParams(location.search).get('aid') ?? new URLSearchParams(location.search).get('id') ?? location.href.match(/book\/(\d+)\.htm/)?.[1] ?? location.href.match(/novel\/\d+\/(\d+)\//)?.[1], 10); core.show(aid); } }) ); } }, autovote: { desc: '每日自动推书', dependencies: ['utils', 'debugging', 'logger', 'configs'], params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { /** @type {utils} */ const utils = require('utils'); /** @type {debugging} */ const debugging = require('debugging'); /** @type {logger} */ const logger = require('logger'); /** @type {configs} */ const configs = require('configs'); /** * @typedef {Object} Book * @property {number} aid * @property {string} name * @property {string} cover - 封面url * @property {number} votes - 每日推书票数 * @property {number} time_added - 添加到自动推书列表的时间 * @property {number} voted - 累计自动推书票数 */ /** * @typedef {Object} VoteRecord * @property {number} last_voted - 上一次执行自动推书的时间 * @property {Record<string, number>} vote_status - 上一次执行自动推书时的推书进度 */ GM_getValue = utils.defaultedGet({ /** @type {Book[]} */ list: [], /** @type {VoteRecord} */ record: { last_voted: 0, vote_status: [], }, /** @type {boolean} */ enabled: true, }, GM_getValue); const Settings = CONST.Text.Autovote.Settings; configs.registerConfig('autovote', { GM_addValueChangeListener, items: [{ type: 'boolean', label: Settings.Enabled, caption: Settings.EnabledCaption, key: 'enabled', reload: true, get() { return GM_getValue('enabled'); }, set(val) { GM_setValue('enabled', val); }, }, { type: 'button', label: Settings.Configuration, button_icon: 'edit_note', button_label: Settings.Configure, async callback() { /** @type {gui} */ const gui = await pool.require('gui', true); gui.show(); }, }], label: Settings.Title, }) const pool_funcs = { core: { desc: '实现推书列表的增删改查', // 这里不用让FunctionLoader包装子存储,直接将list存储在autovote的全局作用域中即可 /** @typedef {Awaited<ReturnType<typeof pool_funcs.core.func>>} core */ func() { // 内容更改监听器 /** @type {((val: Book[]) => any)[]} */ const listeners = []; GM_addValueChangeListener('list', (key, old_val, new_val, remote) => { // 防抖,比对确认确实存在数据差异再回调 // 时间复杂度:对于m本书、每本书n个属性,大致为 O(m) * O(n) const variable_same = old_val === new_val; const both_array = Array.isArray(old_val) === Array.isArray(new_val); const array_same = both_array && old_val.length === new_val.length && old_val.every( /** @param {Book} book */ (book, i) => { const old_book = book; const new_book = new_val[i]; const old_keys = Object.keys(old_book); const new_keys = Object.keys(new_book); if (old_keys.length !== new_keys.length) { return false; } if (old_keys.some((k, j) => k != new_keys[j])) { return false; } if (old_keys.some(k => old_book[k] !== new_book[k])) { return false; } } ) if (variable_same || array_same) { return; } listeners.forEach(l => debugging.callWithErrorHandling(l, null, [new_val])); }); /** * 添加一本书到自动推书 * @param {Book} book * @returns {boolean} 成功添加 / 已经在推书列表中 */ function add(book) { if (has(book.aid)) { return false; } const books = list(); books.push(book); GM_setValue('list', books); return true; } /** * 直接设置整个books数组 * @overload * @param {Book[]} books * @returns {void} */ /** * 设置某一已在推书列表中的书籍的推书票数 * @overload * @param {number} aid * @param {number} votes * @returns {boolean} */ function set(...args) { // 直接设置整个books数组 if (args.length === 1) { const books = args[0]; GM_setValue('list', books); return; } // 设置某一已在推书列表中的书籍的推书票数 if (args.length === 2) { const [aid, votes] = args; if (!has(aid)) { return false; } const books = list(); books.find(b => b.aid === aid).votes = votes; GM_setValue('list', books); return true; } throw new TypeError('autovote.core.set: arguments\' length invalid'); } /** * 检查某一本书是否在推书列表中 * @param {number} aid * @returns {boolean} */ function has(aid) { const books = list(); return books.some(book => book.aid === aid); } /** * 获取全部 * @returns {Book[]} */ function list() { return GM_getValue('list'); } /** * 添加稍后列表值改变监听器 * @param {(val: Book[]) => any} listener */ function onChange(listener) { listeners.push(listener); } return { add, set, has, list, onChange }; } }, bookpage: { desc: '在书籍信息页侧边栏添加自动推书按钮', checkers: [{ type: 'regpath', value: /\/book\/\d+\.htm/ }, { type: 'path', value: '/modules/article/articleinfo.php' }], dependencies: ['core'], async func() { /** @type {core} */ const core = pool.require('core'); /** @type {sidepanel} */ const sidepanel = await require('sidepanel', true); const aid = parseInt(new URLSearchParams(location.search).get('id') ?? location.pathname.match(/\/book\/(\d+)\.htm/)[1], 10); const name = $('#content > div:first-child > table:first-child > tbody > tr:first-child > td > table span > b').innerText.trim(); const cover = $('#content > div:first-child > table:nth-of-type(2) img').src; GM_getValue('enabled') && sidepanel.registerButton({ id: 'autovote.add', label: CONST.Text.Autovote.Add, icon: 'playlist_add', index: 4, callback() { const Autovote = CONST.Text.Autovote; const time_added = Date.now(); const success = core.add({ aid, name, cover, votes: 1, time_added, voted: 0 }); Quasar.Notify.create({ type: 'success', message: Autovote.Added, caption: replaceText( success ? Autovote.AddSuccess : Autovote.AddDuplicate, { '{Name}': name } ), icon: success ? 'done' : 'lightbulb', group: 'autovote.added', }); } }) } }, gui: { desc: '在书架、书籍信息页和设置界面中展示的自动推书配置界面', dependencies: ['core'], detectDom: 'body', /** @typedef {Awaited<ReturnType<typeof pool_funcs.gui.func>>} gui */ async func() { /** @type {core} */ const core = pool.require('core'); const container = $CrE('div'); const UI = CONST.Text.Autovote.UI; container.innerHTML = ` <q-dialog v-model="visible" full-width full-height class="plus-autovote"> <q-layout container view="hHh lpR fFf"> <q-header bordered class="bg-primary text-white"> <q-toolbar> <q-toolbar-title> <q-avatar icon="collections_bookmark"></q-avatar> ${ UI.Title } </q-toolbar-title> <q-btn icon="close" flat v-close-popup></q-btn> </q-toolbar> </q-header> <q-page-container> <q-page class="bg-lightdark q-pa-md"> <q-list> <q-item v-for="(book, i) of books"> <q-item-section avatar> <a :href="book_urls[book.aid]" style="width: 100%;" target="_blank"> <q-img :src="book.cover"></q-img> </a> </q-item-section> <q-item-section> <q-item-label class="text-body1"> <a :href="book_urls[book.aid]" target="_blank">{{ book.name }}</a> </q-item-label> <q-item-label caption> ${ UI.TimeAdded }{{ new Date(book.time_added).toLocaleDateString() }} </q-item-label> <q-item-label caption> ${ UI.VotedCount }{{ book.voted }} </q-item-label> </q-item-section> <q-item-section avatar> <q-input v-model.number="book.votes" label="${ UI.Votes }"></q-input> </q-item-section> <q-item-section avatar> <q-btn icon="delete_outline" @click="remove(book.aid)" flat></q-btn> </q-item-section> </q-item> </q-list> </q-page> </q-page-container> </q-layout> </q-dialog> `; document.body.append(container); let instance; const app = Vue.createApp({ data() { return { visible: false, books: core.list(), }; }, computed: { /** * 根据书籍aid自动合成的书籍信息页链接 * @type {Record<number | string, string>} */ book_urls() { return this.books.reduce((urls, book) => Object.assign(urls, { [book.aid]: `/book/${ book.aid }.htm`}), {}); }, }, methods: { /** * 删除一个自动推书项(即一本书) * @param {number} aid */ remove(aid) { const book = this.books.find(b => b.aid === aid); Quasar.Dialog.create({ title: UI.ConfirmRemove.Title, message: replaceText( UI.ConfirmRemove.Message, { '{Name}': book.name } ), ok: { label: UI.ConfirmRemove.Ok, color: 'primary', }, cancel: { label: UI.ConfirmRemove.Cancel, color: 'secondary', }, }).onOk(() => this.books.splice(this.books.findIndex(book => book.aid === aid), 1)) } }, watch: { // 自动保存配置更改到存储空间 books: { handler(new_val, old_val) { core.set(new_val); }, deep: true }, }, mounted() { instance = this; // 自动根据存储的推书配置更新UI core.onChange(books => this.books = books); } }); app.use(Quasar); app.mount(container); function show() { instance.visible = true; } function hide() { instance.visible = false; } if (FunctionLoader.testCheckers([{ type: 'regpath', value: /\/book\/\d+\.htm/ }, { type: 'path', value: '/modules/article/articleinfo.php' }, { type: 'path', value: '/modules/article/bookcase.php' }]) && GM_getValue('enabled')) { require('sidepanel', true).then( /** @param {sidepanel} sidepanel */ sidepanel => { sidepanel.registerButton({ id: 'autovote.show', icon: 'edit_note', label: CONST.Text.Autovote.Configure, index: 4, callback: show, }); } ); } return { show, hide, } }, }, vote: { desc: '每天执行一次推书任务', dependencies: ['core'], // 这里不用让FunctionLoader包装子存储,直接将推书记录存储在autovote的全局作用域中即可 async func() { /** @type {core} */ const core = pool.require('core'); const record = getRecord(); const books = core.list(); // 如果没有开启自动推书,停止运行 if (!GM_getValue('enabled')) { logger.log('Info', 'Autovote: autovote not enabled'); return; } // 如果今日已经完成了自动推书,停止运行 const today_voted = new Date(record.last_voted).toDateString() === new Date().toDateString(); const vote_completed = books.every(book => record.vote_status[book.aid] >= book.votes); if (today_voted && vote_completed) { logger.log('Info', 'Autovote: today voted'); return; } // 如果有其他页面内的脚本实例正在执行推书任务,当前实例就不重复执行 const autovote_active = Date.now() - record.last_voted <= CONST.Internal.AutovoteActiveTimeout; if (autovote_active) { logger.log('Info', 'Autovote: voting active in another page'); return; } const voteBook = utils.toQueued(_voteBook, { max: 5, sleep: 0, queue_id: 'votebook' }); // 执行自动推书 logger.log('Info', 'Autovote: start voting'); Quasar.Notify.create({ type: 'info', message: CONST.Text.Autovote.VoteStart, group: 'autovote.vote', }); const divs = await doAutovote(); Quasar.Notify.create({ type: 'success', message: CONST.Text.Autovote.VoteEnd, /*actions: [{ label: CONST.Text.Autovote.VoteDetail, handler() { Quasar.Dialog.create({ // }); } }],*/ group: 'autovote.vote', }); /** * 根据今日推书状态,为未推完部分执行自动推书 * @returns {Promise<Record<string, HTMLDivElement[]>>} { [书籍字符串aid]: (推书结果文档中的block)[] } */ async function doAutovote() { const record = getRecord(); const books = core.list(); // 筛选出今日未推完的书,并计算剩余推书票数 /** @type {Record<string, number>} 未推完的书及其剩余推书票数 */ const task = books.reduce((task, book) => { const str_aid = book.aid.toString(); // 上次自动推书是不是今天 const today_voted = new Date(record.last_voted).toDateString() === new Date().toDateString(); // 这本书每天应该推的总票数 const total = books.find(b => b.aid === book.aid).votes; // 这本书今日还应推的票数 const rest = today_voted ? Math.max(0, total - (record.vote_status[str_aid] ?? 0)) : total; rest > 0 && (task[str_aid] = rest); return task; }, {}); // 推书 const result = {}; await Promise.all(Object.entries(task).map(async ([str_aid, votes]) => { const aid = parseInt(str_aid, 10); const divs = await Promise.all(Array.from('a'.repeat(votes)).map((_, i) => voteBook(aid))); result[str_aid] = divs; }, {})); // 更新最后推书完成时间,确保哪怕没有任何书要推也每天仅执行一次 const new_record = getRecord(); new_record.last_voted = Date.now(); GM_setValue('record', new_record); return result; } /** * 执行推书一次(投一票),并记到推书记录中 * @param {number} aid * @returns {Promise<HTMLDivElement>} 返回的页面中的.block元素 */ async function _voteBook(aid) { // 推书 const str_aid = aid.toString(); const doc = await utils.requestDocument({ method: 'GET', url: `/modules/article/uservote.php?id=${str_aid}`, }); const block = $(doc, '.block'); block || logger.log('Warn', 'Autovote: .block not found in vote page', doc); // 记录 const record = getRecord(); const books = core.list(); // 如果上次自动推书不是今天,就先清除推书记录 const today_voted = new Date(record.last_voted).toDateString() === new Date().toDateString(); today_voted || (record.vote_status = {}); // 推书记录中为当前书籍已推书计数加一 record.vote_status[str_aid] = (record.vote_status[str_aid] ?? 0) + 1; // 自动推书配置中累计推书次数加一 books.find(b => b.aid === aid).voted++; // 更新推书记录中的时间 record.last_voted = Date.now(); // 保存 GM_setValue('record', record); core.set(books); return block; } /** * 获取自动推书记录 * @returns {VoteRecord} */ function getRecord() { return GM_getValue('record'); } } }, }; const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { GM_setValue, GM_getValue, GM_addValueChangeListener }); await promise; }, }, reviewcollection: { desc: '书评收藏', dependencies: ['utils', 'configs'], params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { /** @type {utils} */ const utils = require('utils'); /** @type {configs} */ const configs = require('configs'); /** * @typedef {Object} Review * @property {number} rid * @property {string} name */ GM_getValue = utils.defaultedGet({ /** @type {Review[]} */ reviews: CONST.Internal.BuiltinReviewCollection, /** @type {boolean} */ enabled: true, /** @type {'left' | 'right'} */ list_position: 'left', /** @type {boolean} */ open_lastpage: false, }, GM_getValue); const Settings = CONST.Text.ReviewCollection.Settings; configs.registerConfig('reviewcollection', { GM_addValueChangeListener, items: [{ type: 'boolean', label: Settings.Enabled, caption: Settings.EnabledCaption, key: 'enabled', get() { return GM_getValue('enabled'); }, set(val) { GM_setValue('enabled', val); }, }, { type: 'select', options: [{ label: Settings.ListPositionLeft, value: 'left', }, { label: Settings.ListPositionRight, value: 'right', }], label: Settings.ListPosition, caption: Settings.ListPositionCaption, key: 'list_position', get() { return GM_getValue('list_position'); }, set(val) { GM_setValue('list_position', val); }, }, { type: 'boolean', label: Settings.OpenLastPage, caption: Settings.OpenLastPageCaption, key: 'open_lastpage', get() { return GM_getValue('open_lastpage'); }, set(val) { GM_setValue('open_lastpage', val); }, }], label: Settings.Title, }); const pool_funcs = { /*/ gui: { desc: '收藏书评管理界面', async func() { const container = $CrE('div'); container.innerHTML = ` <q-dialog v-model="visible"> </q-dialog> `; }, }, */ indexpage: { desc: '在首页展示收藏的书评列表', checkers: [{ type: 'path', value: '/' }, { type: 'path', value: '/index.php' }], async func() { // 页面内列表 makeList(); configs.registerUpdateCallback('reviewcollection', (key, old_val, new_val, remote) => { switch (key) { case 'enabled': new_val ? makeList() : $('#plus-review-collection')?.remove(); break; case 'list_position': case 'open_lastpage': makeList(); break; } }); GM_addValueChangeListener('reviews', () => makeList()); addStyle(` .ultop { overflow-x: hidden; } `); /** * 创建书评列表展示框并添加到DOM,如DOM已有展示框就替换掉旧的 */ function makeList() { // 如果没有启用就不创建 if (!GM_getValue('enabled')) { return; } /** @type {Review[]} */ const reviews = GM_getValue('reviews'); // 制作列表 const block = $$CrE({ tagName: 'div', classes: 'block', props: { innerHTML: ` <div class="blocktitle"> <span class="txt">${ CONST.Text.ReviewCollection.CollectionTitle }</span> <span class="txtr"></span> </div> <div class="blockcontent"> <ul class="ultop"></ul> </div> `, }, attrs: { id: 'plus-review-collection', }, }); const ul = $(block, '.ultop'); reviews.forEach(review => { const url = `https://${ location.host }/modules/article/reviewshow.php?rid=${ review.rid }&page=${ GM_getValue('open_lastpage') ? 'last' : '1' }`; const li = $CrE('li'); const a = $$CrE({ tagName: 'a', attrs: { href: url, target: '_blank', }, props: { innerText: review.name, }, }); utils.setTip(a, review.name); li.append(a); ul.append(li); }); // 添加到页面 $('#plus-review-collection')?.remove(); const parent = $(({ left: '#left', right: '#right', }) [GM_getValue('list_position')]); parent.append(block); } }, }, reviewpage: { desc: '在书评页面添加收藏按钮', checkers: { type: 'path', value: '/modules/article/reviewshow.php', }, async func() { /** @type {sidepanel} */ const sidepanel = await require('sidepanel', true); toggleSideButton(); configs.registerUpdateCallback('reviewcollection', { enabled(key, old_val, new_val, remote) { toggleSideButton(); } }); /** * 根据enabled,注册(不可用)或移除侧边栏收藏按钮 * @param {boolean} [enabled] */ function toggleSideButton(enabled=null) { enabled === null && (enabled = GM_getValue('enabled')); const ButtonID = 'reviewcollection.toggle'; const ReviewCollection = CONST.Text.ReviewCollection; const rid = parseInt(new URLSearchParams(location.search).get('rid'), 10); let in_collection = GM_getValue('reviews').some(r => r.rid === rid); if (enabled) { sidepanel.registerButton({ id: ButtonID, label: in_collection ? ReviewCollection.Remove : ReviewCollection.Add, icon: in_collection ? 'bookmark' : 'bookmark_border', index: 2, callback() { in_collection = !in_collection; // 修改书评收藏 /** @type {Review[]} */ const reviews = GM_getValue('reviews'); const rid = parseInt(new URLSearchParams(location.search).get('rid'), 10); let name = $('#content > table.grid th > strong').innerText.trim(); name.includes(':') && (name = name.split(':')[1]); if (in_collection) { // 需要添加书评收藏 // 因为是内存存储收藏状态而非实时检测配置存储,因此需要防止重复添加 reviews.every(r => r.rid !== rid) && reviews.push({ rid, name }); } else { // 需要移除书评收藏 // 因为是内存存储收藏状态而非实时检测配置存储,因此需要确保目前确实是已收藏状态 const index = reviews.findIndex(r => r.rid === rid); index >= 0 && reviews.splice(index, 1); } GM_setValue('reviews', reviews); // 提示 Quasar.Notify.create({ type: 'success', message: in_collection ? ReviewCollection.Added : ReviewCollection.Removed, group: 'reviewcollection.toggle' }); // 更新按钮 sidepanel.updateButton(ButtonID, { label: in_collection ? ReviewCollection.Remove : ReviewCollection.Add, icon: in_collection ? 'bookmark' : 'bookmark_border', }); } }); } else { sidepanel.hasButton(ButtonID) && sidepanel.removeButton(ButtonID); } } } }, }; const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { GM_setValue, GM_getValue, GM_addValueChangeListener }); await promise; }, }, background: { desc: '自定义页面背景', detectDom: 'body', dependencies: ['utils', 'configs'], params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], func(GM_setValue, GM_getValue, GM_addValueChangeListener) { /** @type {utils} */ const utils = require('utils'); /** @type {configs} */ const configs = require('configs'); /** * @typedef {'local' | 'url' | 'color'} BGType */ GM_getValue = utils.defaultedGet({ /** @type {boolean} */ enabled: false, /** @type {BGType} */ type: 'color', /** @type {string} */ image_url: '', /** @type {'contain' | 'cover' | 'fill' | 'none' | 'scale-down'} */ image_fit: 'fill', /** @type {number} */ mask_opacity: 0.5, /** @type {string} */ color: 'rgb(255, 255, 255)', }, GM_getValue); // 创建背景 /** * 背景管理器 * @typedef {{ install: function, update: function, uninstall: function }} BackgroundManager */ /** * 当前已经应用背景的管理器 * @type {BackgroundManager | null} */ let cur_bg = null; /** * 已实现的全部背景管理器 * @satisfies {Record<string, BackgroundManager>} */ const BG = { image: { /** * @param {string} url * @param {number} mask_opacity */ install(url, mask_opacity, image_fit) { const img = $$CrE({ tagName: 'img', attrs: { src: url, id: 'plus-background-img', }, styles: { position: 'fixed', left: '0', top: '0', width: '100vw', height: '100vh', zIndex: '-2', display: url ? 'block' : 'none', objectFit: image_fit, }, }); const mask_container = $$CrE({ tagName: 'div', classes: ['plus-main'], attrs: { id: 'plus-background-mask-container' }, styles: { position: 'relative', height: '0' } }); const mask = $$CrE({ tagName: 'div', attrs:{ id: 'plus-background-mask', }, styles: { position: 'absolute', bottom: '0', left: '0', width: '960px', height: '10000vh', zIndex: '-1', opacity: `${mask_opacity}`, } }); mask_container.append(mask); document.body.append(img, mask_container); addStyle(` /* 网页自带背景调成透明 */ body:is(.plus-darkmode, :not(.plus-darkmode)):not(#StrongerThanDarkmode) { background-color: transparent; } :is(body.plus-darkmode, body:not(.plus-darkmode)) :is(table.grid td, .blockcontent, .even, .odd) { background: transparent !important; } .plus-main{ width: 960px; clear: both; text-align: center; margin-left: auto; margin-right: auto; margin-top:3px; } #plus-background-mask { background: white; } .plus-darkmode #plus-background-mask { background: black; } `, 'plus-background-style'); }, update(url, mask_opacity, image_fit) { $('#plus-background-img').src = url; $('#plus-background-img').style.objectFit = image_fit; $('#plus-background-mask').style.opacity = `${mask_opacity}`; }, uninstall() { $('#plus-background-img')?.remove(); $('#plus-background-mask')?.remove(); $('#plus-background-style')?.remove(); }, }, color: { /** * @param {string} color */ install(color) { document.body.append($$CrE({ tagName: 'div', attrs: { id: 'plus-background-block', }, styles: { position: 'fixed', left: '0', top: '0', width: '100vw', height: '100vh', backgroundColor: color, zIndex: '-1', }, })); addStyle(` /* 网页自带背景调成透明 */ body:is(.plus-darkmode, :not(.plus-darkmode)):not(#StrongerThanDarkmode) { background-color: transparent; } :is(body.plus-darkmode, body:not(.plus-darkmode)) :is(table.grid td, .blockcontent, .even, .odd) { background: transparent !important; } `, 'plus-background-style'); }, update(color) { $('#plus-background-block').style.background = color; }, uninstall() { $('#plus-background-block')?.remove(); $('#plus-background-style')?.remove(); } } }; applyBackground(); // 注册(不可用)设置,设置切换时实时应用 const Settings = CONST.Text.Background.Settings; configs.registerConfig('background', { GM_addValueChangeListener, items: [{ type: 'boolean', label: Settings.Enabled, caption: Settings.EnabledCaption, key: 'enabled', get() { return GM_getValue('enabled'); }, set(val) { GM_setValue('enabled', val); }, }, { type: 'select', label: Settings.Type, options: Settings.Types, key: 'type', get() { return GM_getValue('type'); }, set(val) { GM_setValue('type', val); }, }, { type: 'string', label: Settings.ImageUrl, key: 'image_url', get() { return GM_getValue('image_url'); }, set(val) { GM_setValue('image_url', val); }, }, { type: 'image', label: Settings.Image, key: 'image', callback: applyBackground, reload: 'page', async get() { // 从 OPFS:%Module%//background/image 中取出blob const root = await utils.getModuleDir('background'); let has_image = false; for await (const key of root.keys()) { if (key === 'image') { has_image = true; break; } } if (has_image) { const image = await root.getFileHandle('image', { create: true }); const file = await image.getFile(); return file; } else { return null; } }, /** * @param {File} file */ async set(file) { // 写入到 OPFS:%Module%//background/image const root = await utils.getModuleDir('background'); const image = await root.getFileHandle('image', { create: true }); const writable = await image.createWritable({ keepExistingData: false, mode: 'exclusive' }); const buffer = await file.arrayBuffer(); await writable.write(buffer); await writable.close(); }, }, { type: 'range', label: Settings.MaskOpacity, range: { max: 1, min: 0, step: 0.05, }, key: 'mask_opacity', get() { return GM_getValue('mask_opacity'); }, set(val) { GM_setValue('mask_opacity', val); }, }, { type: 'color', label: Settings.Color, key: 'color', get() { return GM_getValue('color'); }, set(val) { GM_setValue('color', val); }, }, { type: 'choose', label: Settings.ImageFit, options: Settings.ImageFitOptions, key: 'image_fit', get() { return GM_getValue('image_fit'); }, set(val) { GM_setValue('image_fit', val); }, }], label: Settings.Title, listeners: applyBackground, }); /** * 根据设置应用背景 */ async function applyBackground() { // 如果未启用背景功能,卸载现有背景并退出 if (!GM_getValue('enabled')) { cur_bg !== null && cur_bg.uninstall(); cur_bg = null; return; } // 目前应使用的背景类型及对应的背景管理器 /** @type {BGType} */ const type = GM_getValue('type'); const new_bg = ({ 'url': BG.image, 'local': BG.image, 'color': BG.color, }) [type]; // 传递给背景管理器的参数 /** @type {any[]} */ let args = []; switch (type) { case 'url': args = [ GM_getValue('image_url'), GM_getValue('mask_opacity'), GM_getValue('image_fit'), ]; break; case 'local': { const root = await utils.getModuleDir('background'); const image = await root.getFileHandle('image', { create: true }); const file = await image.getFile(); const url = URL.createObjectURL(file); args = [ url, GM_getValue('mask_opacity'), GM_getValue('image_fit'), ]; break; } case 'color': args = [GM_getValue('color')]; break; } // 如果背景类型不变,调用更新方法,否则卸载当前背景,安装新背景 if (cur_bg === new_bg) { new_bg.update.apply(null, args); } else { cur_bg && cur_bg.uninstall(); new_bg.install.apply(null, args); } // 更新当前背景管理器 cur_bg = new_bg; } }, }, openlastpage: { desc: '书评打开尾页', checkers: [ // 书籍信息页 { type: 'regpath', value: /\/book\/\d+\.htm/ }, { type: 'path', value: '/modules/article/articleinfo.php' }, // 书评列表页 { type: 'path', value: '/modules/article/reviews.php' }, { type: 'path', value: '/modules/article/reviewslist.php' }, ], async func() { detectDom({ selector: 'a[href*="/modules/article/reviewshow.php"]', /** * @param {HTMLAnchorElement} a */ callback(a) { if (a.pathname !== '/modules/article/reviewshow.php') { return; } a.before($$CrE({ tagName: 'span', props: { innerText: CONST.Text.OpenLastPage.OpenLastPageButton, }, styles: { color: 'var(--q-primary)', cursor: 'pointer', paddingRight: '0.3em', }, listeners: [['click', e => { const str_rid = new URLSearchParams(a.search).get('rid'); window.open(`/modules/article/reviewshow.php?rid=${ str_rid }&page=last`); }]], })); } }) }, }, styling: { desc: '样式管理器', disabled: false, detectDom: 'head', dependencies: ['utils', 'configs'], params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], func(GM_setValue, GM_getValue, GM_addValueChangeListener) { /** @type {utils} */ const utils = require('utils'); /** @type {configs} */ const configs = require('configs'); // 控制性样式表,用于对文库自带样式表进行一一对应地覆盖 // 格式:Record<文库自带样式表相对路径, 样式表内容> const ControllingStyleSheets = { '/themes/wenku8/style.css': `:root{--plus-bg-1:white;--plus-text-1:black;--plus-anchor:#4a4a4a;--plus-anchor-hover:#0033ff;--plus-border:#a4cded;--plus-border-light:#a3bee8;--plus-border-dialog:#8bcee4;--plus-bg-th-caption:#e9f1f8;--plus-bg-blocktitle:#d1e4fd;--plus-bgimg-caption:url("/themes/wenku8/image/caption_bg.gif");--plus-text-input:#054e86;--plus-text-th:#054e86;--plus-text-th-withbgimg:#0049a0;--plus-bg-button:#ddf2ff;--plus-bgimg-wrapper:url("/themes/wenku8/image/tabbg1_1.gif");--plus-bgimg-mtop:url("/themes/wenku8/image/m_top_bg.gif");--plus-bgimg-txt:url("/themes/wenku8/image/title_l.gif");--plus-bgimg-txtr:url("/themes/wenku8/image/title_r.gif");--plus-bgimg-blocktitle:url("/themes/wenku8/image/title_bg.gif");--plus-bgimg-nav:url("/themes/wenku8/image/nav_bg.png");--plus-bgimg-userinfo:url("/themes/wenku8/image/userinfo.gif");--plus-bg-2:#f0f7ff;--plus-pagelink-strong:#ff6600;--plus-text-ultop:#1b74bc;--plus-underline-ultop:#d8e4ef;--plus-text-poptext:#c42205;--plus-text-hottext:#ff0000;--plus-text-notetext:#1979cc;--plus-border-jieqi:#000000;--plus-bg-jieqi:#a4cded;--plus-text-nav:#fff;--plus-bg-mask:#777777;--plus-bg-dialog:#f1f5fa}body{background:var(--plus-bg-1)}a{color:var(--plus-anchor)}a:hover{color:var(--plus-anchor-hover)}hr{border:1px solid var(--plus-border)}table.grid{border:1px solid var(--plus-border)}table.grid caption,.gridtop{border:1px solid var(--plus-border);background:var(--plus-bg-th-strong);background-image:var(--plus-bgimg-caption);color:var(--plus-text-th)}table.grid th,.head{border:1px solid var(--plus-border);background:var(--plus-bg-2);color:var(--plus-text-th)}table.grid td{border:1px solid var(--plus-border);background-color:var(--plus-bg-1)!important}.title{background:var(--plus-bg-th-caption);color:var(--plus-text-th)}.even{background:var(--plus-bg-1)}.odd{background:var(--plus-bg-1)}.foot{background:var(--plus-bg-2)}.bottom{background:#b7b785}.text{border:1px solid var(--plus-border);background:var(--plus-bg-1);color:var(--plus-text-input);height:18px}.textarea{border:1px solid var(--plus-border);background:var(--plus-bg-1);color:var(--plus-text-input)}.button{background:var(--plus-bg-button);border:1px solid var(--plus-border);height:20px}#wrapper{background:var(--plus-bgimg-wrapper)}.m_top{background-image:var(--plus-bgimg-mtop)}.m_menu{background:#55a0ff;border-top:1px solid #e4e4e4;border-bottom:1px solid #e4e4e4}.m_foot{border-top:1px dashed var(--plus-border);border-bottom:1px dashed var(--plus-border)}.blocktop{border:1px solid var(--plus-border)}.blockcaption{background:var(--plus-bg-th-caption);background-image:var(--plus-bgimg-caption);color:var(--plus-text-th)}.block{border:1px solid var(--plus-border)}.blocktitle{border-top:2px solid var(--plus-bg-1);border-bottom:1px solid var(--plus-bg-1);border-left:2px solid var(--plus-bg-1);border-right:1px solid var(--plus-bg-1);background:var(--plus-bg-blocktitle);color:var(--plus-text-th)}.blockcontent{border-top:1px solid var(--plus-border-light);padding:3px}.blockcontenttop{border-top:1px solid var(--plus-border-light);border-bottom:1px solid var(--plus-border-light);padding:3px}.blocknote{border-top:1px solid var(--plus-border);background:var(--plus-bg-2)}.blocktitle span0{border-top:1px solid var(--plus-border);border-left:1px solid var(--plus-border);border-right:1px solid var(--plus-border);background:var(--plus-bg-1);color:var(--plus-text-poptext)}.blocktitle .txt{background-image:var(--plus-bgimg-txt);color:var(--plus-text-th-withbgimg)}.blocktitle .txtr{background-image:var(--plus-bgimg-txtr)}.gameblocktop{border:1px solid var(--plus-border)}.gameblockcontent{border-top:1px solid var(--plus-border-light)}.appblocktop{border:1px solid var(--plus-border)}.appblockcaption{background:var(--plus-bg-th-caption);background-image:var(--plus-bgimg-caption);color:var(--plus-text-th)}.appblockcontent{border-top:1px solid var(--plus-border-light)}#left .blocktitle,#right .blocktitle{background-image:var(--plus-bgimg-blocktitle)}#left .blockcontent,#right .blockcontent{background:var(--plus-bg-1)}.ultop li{border-bottom:1px dashed var(--plus-underline-ultop);color:var(--plus-text-ultop)}.ultop li a{color:var(--plus-text-poptext)}.ultops li{border-bottom:1px dashed var(--plus-underline-ultop);color:var(--plus-text-ultop)}.ultops li a{color:var(--plus-text-poptext)}.hottext,a.hottext{color:var(--plus-text-hottext)}.poptext,a.poptext{color:var(--plus-text-poptext)}.notetext,a.notetext{color:var(--plus-text-notetext)}.errortext,a.errortext{color:var(--plus-text-hottext)}a.btnlink{color:#535353;background:var(--plus-bg-1);border:0 solid var(--plus-border)}a.btnlink:hover{background:var(--plus-bg-1)}a.btnlink1{color:#535353;background:var(--plus-bg-1);border:0 solid var(--plus-border)}a.btnlink1:hover{background:var(--plus-bg-1)}a.btnlink2{color:#535353;background:var(--plus-bg-button);border:1px solid var(--plus-border)}a.btnlink2:hover{background:#cccccc}.jieqiQuote,.jieqiCode,.jieqiNote{border:var(--plus-border-jieqi) 1px solid;color:var(--plus-text-1);background-color:var(--plus-bg-jieqi)}.divbox{border:1px solid var(--plus-border)}.textbox{border:1px solid var(--plus-border)}.popbox{border:1px solid var(--plus-border);background:var(--plus-bg-2);color:var(--plus-text-hottext)}#tips{border:1px solid var(--plus-border);background:var(--plus-bg-2)}.tablist li a{background:var(--plus-bg-2);color:var(--plus-text-1);border:1px solid var(--plus-border)}.tablist li a.selected{background:var(--plus-bg-1)}.tabcontent{border:1px solid var(--plus-border)}.pagelink{border:1px solid var(--plus-border);background:var(--plus-bg-2)}.pagelink a:hover{background-color:var(--plus-bg-1)}.pagelink strong{color:var(--plus-pagelink-strong);background:var(--plus-bg-th-caption)}.pagelink kbd{border-left:1px solid var(--plus-border)}.pagelink em{border-right:1px solid var(--plus-border)}.pagelink input{border:1px solid var(--plus-border);color:var(--plus-text-input)}.nav{background:var(--plus-bgimg-nav) no-repeat 0 -36px}.navinner{background:var(--plus-bgimg-nav) no-repeat 100% -72px}.navlist{background:var(--plus-bgimg-nav) repeat-x 0 0}.nav li{background:var(--plus-bgimg-nav) no-repeat 0 -108px}.nav a:link,.nav a:visited{color:var(--plus-text-nav);text-decoration:none}.nav a.current,.nav a:hover,.nav a:active{color:var(--plus-text-nav);background:var(--plus-bgimg-nav) no-repeat 50% -144px}.subnav{background:var(--plus-bgimg-nav) no-repeat 0 -180px}.subnav p{background:var(--plus-bgimg-nav) no-repeat 100% -234px}.subnav p span{background:var(--plus-bgimg-nav) repeat-x 0 -207px}.subnav p.pointer{background:var(--plus-bgimg-nav) repeat-x 0 -261px}.subnav,.subnav a:link,.subnav a:visited{color:#235e99}.subnav a:hover,.subnav a:active{color:#235e99}.ajaxtip{border:1px solid var(--plus-border-light);background:var(--plus-bg-2);color:var(--plus-text-hottext)}#tips{border:1px solid var(--plus-border-light);background:var(--plus-bg-2)}#dialog{border:5px solid var(--plus-border-dialog);background:var(--plus-bg-dialog)}#mask{background:var(--plus-bg-mask)}.userinfo_001{background:var(--plus-bgimg-userinfo) 0 0 no-repeat}.userinfo_002{background:var(--plus-bgimg-userinfo) 0px -16px no-repeat}.userinfo_003{background:var(--plus-bgimg-userinfo) 0px -34px no-repeat}.userinfo_004{background:var(--plus-bgimg-userinfo) 0px -54px no-repeat}.userinfo_005{background:var(--plus-bgimg-userinfo) 0px -73px no-repeat}.userinfo_006{background:var(--plus-bgimg-userinfo) 0px -94px no-repeat}.userinfo_007{background:var(--plus-bgimg-userinfo) 0px -113px no-repeat}.userinfo_008{background:var(--plus-bgimg-userinfo) 0px -133px no-repeat}img.avatars{border:1px solid #dddddd}`, '/configs/article/page.css': ``, }; GM_getValue = utils.defaultedGet({ }, GM_getValue); install(); /** * 安装所有控制性样式表到页面 */ function install() { Array.from($All('link[rel="stylesheet"][href]')).forEach(link => { const pathname = new URL(link.href).pathname; const id = `plus-styling-${pathname}`.replaceAll('/', '_'); ControllingStyleSheets.hasOwnProperty(pathname) && addStyle(ControllingStyleSheets[pathname], id); }); } } }, blocking: { desc: '屏蔽功能', disabled: true, dependencies: ['dependencies', 'utils', 'configs'], params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], /** @typedef {Awaited<ReturnType<typeof functions.blocking.func>>} blocking */ async func(GM_setValue, GM_getValue, GM_addValueChangeListener) { /** @type {utils} */ const utils = require('utils'); /** @type {configs} */ const configs = require('configs'); /** * @typedef {Object} BlockTarget * @property {'user' | 'book'} type * @property {number} id */ GM_getValue = utils.defaultedGet({ /** @type {BlockTarget[]} */ blocklist: [], /** @type {boolean} */ enabled: true, }, GM_getValue); const pool_funcs = { userblock: { desc: '屏蔽用户', async func() { const pool_funcs = { bookreviewlist: { desc: '书籍信息页和书籍书评列表页的书评屏蔽', checkers: [ // 书籍信息页 { type: 'regpath', value: /\/book\/\d+\.htm/ }, { type: 'path', value: '/modules/article/articleinfo.php' }, // 书籍书评列表页 { type: 'path', value: '/modules/article/reviews.php' } ], func() { addStyle(` .plus-blocking-blocked { display: none; } `, 'plus-blocking'); detectDom({ selector: 'table.grid td:nth-of-type(3) > a[href*="userpage.php"]', callback: a => dealBlocking(a) }); GM_addValueChangeListener('blocklist', (key, old_val, new_val, remote) => { // 当屏蔽状态改变时,改变书评条目的隐藏/显示状态 Array.from($All('table.grid td:nth-of-type(3) > a[href*="userpage.php"]')).forEach(a => dealBlocking(a)); }); /** * 给定书评条目中的用户链接元素,根据屏蔽状态隐藏/显示此书评条目 * @param {HTMLAnchorElement} a */ function dealBlocking(a) { const uid = parseInt(new URLSearchParams(a.search).get('uid'), 10); userBlocked(uid) ? a.closest('tr').classList.add('plus-blocking-blocked') : a.closest('tr').classList.remove('plus-blocking-blocked'); } }, }, reviewlist: { desc: '书评列表页书评屏蔽', checkers: { type: 'path', value: '/modules/article/reviewslist.php' }, func() { addStyle(` .plus-blocking-blocked { display: none; } `, 'plus-blocking'); detectDom({ selector: 'table.grid td:nth-of-type(4) > a[href*="userpage.php"]', callback: a => dealBlocking(a) }); GM_addValueChangeListener('blocklist', (key, old_val, new_val, remote) => { // 当屏蔽状态改变时,改变书评条目的隐藏/显示状态 Array.from($All('table.grid td:nth-of-type(4) > a[href*="userpage.php"]')).forEach(a => dealBlocking(a)); }); /** * 给定书评条目中的用户链接元素,根据屏蔽状态隐藏/显示此书评条目 * @param {HTMLAnchorElement} a */ function dealBlocking(a) { const uid = parseInt(new URLSearchParams(a.search).get('uid'), 10); userBlocked(uid) ? a.closest('tr').classList.add('plus-blocking-blocked') : a.closest('tr').classList.remove('plus-blocking-blocked'); } } }, userpage: { desc: '用户主页', checkers: { type: 'path', value: '/userpage.php' }, async func() { /** @type {userpage} */ const userpage = await require('userpage', true); const page = userpage.PageManager.page; const uid = parseInt(new URLSearchParams(location.search).get('uid'), 10); makeButton(); makeLine(); GM_addValueChangeListener('blocklist', (key, old_val, new_val, remote) => { // 当屏蔽状态改变时,重新制作屏蔽/解除屏蔽按钮 if (isBlocked(uid, 'user', old_val) !== isBlocked(uid, 'user', new_val)) { makeButton(); makeLine(); } }); /** * 根据目前屏蔽状态,(重新)安装屏蔽/解除屏蔽按钮 */ function makeButton() { userpage.PageManager.transformer.removeUserButton(page, 'block'); userpage.PageManager.transformer.addUserButton(page, { id: 'block', label: userBlocked(uid) ? CONST.Text.Blocking.UnBlockUser : CONST.Text.Blocking.BlockUser, index: 3, callback: () => userBlocked(uid) ? unBlockUser(uid) : blockUser(uid), }); } /** * 根据目前屏蔽状态,添加/移除屏蔽提示 */ function makeLine() { userBlocked(uid) ? userpage.PageManager.transformer.addUserLine(page, { id: 'block', line: CONST.Text.Blocking.UserBlocked, index: 2, }) : userpage.PageManager.transformer.removeLine(page, 'block'); } } } }; const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { GM_setValue, GM_getValue, GM_addValueChangeListener }); await promise; } }, }; const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { GM_setValue, GM_getValue, GM_addValueChangeListener }); await promise; /** * 屏蔽指定用户 * @param {number} uid */ function unBlockUser(uid) { unblock(uid, 'user'); } /** * 屏蔽指定书籍 * @param {number} aid */ function unBlockBook(aid) { unblock(aid, 'book'); } /** * 解除屏蔽某对象 * @param {number} id * @param {'user' | 'book'} type */ function unblock(id, type) { if (!({ 'user': userBlocked, 'book': bookBlocked, })[type](id)) { return; } /** @type {BlockTarget[]} */ const blocklist = GM_getValue('blocklist'); const index = blocklist.findIndex(target => target.id === id && target.type === type); blocklist.splice(index, 1); GM_setValue('blocklist', blocklist); } /** * 屏蔽指定用户 * @param {number} uid */ function blockUser(uid) { block(uid, 'user'); } /** * 屏蔽指定书籍 * @param {number} aid */ function blockBook(aid) { block(aid, 'book'); } /** * 屏蔽给定对象 * @param {number} id * @param {'user' | 'book'} type */ function block(id, type) { if (({ 'user': userBlocked, 'book': bookBlocked, })[type](id)) { return; } /** @type {BlockTarget[]} */ const blocklist = GM_getValue('blocklist'); blocklist.push({ id, type }); GM_setValue('blocklist', blocklist); } /** * 检查用户是否被屏蔽 * @param {number} uid * @returns {boolean} */ function userBlocked(uid) { return isBlocked(uid, 'user'); } /** * 检查书籍是否被屏蔽 * @param {number} aid * @returns {boolean} */ function bookBlocked(aid) { return isBlocked(aid, 'book'); } /** * 检查给定对象是否被屏蔽 * @param {number} id * @param {'book' | 'user'} type * @param {BlockTarget[]} [blocklist] - 如果提供,则根据此blocklist检查其中是否含有给定对象 * @returns {boolean} */ function isBlocked(id, type, blocklist=null) { blocklist = blocklist ?? GM_getValue('blocklist'); return blocklist.some(target => target.id === id && target.type === type); } } }, }; const oFuncs = Object.entries(functions).reduce((arr, [id, oFunc]) => { oFunc.id = id; arr.push(oFunc); return arr; }, []); default_pool.catch_errors = true; loadFuncs(oFuncs); }) ();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址