您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
轻小说文库全方位体验改善,涵盖阅读、下载、书架、推荐、书评、账号、页面个性化等各种方面,你能想到的这里都有。没有?欢迎提出你的点子。
// ==UserScript== // @name 轻小说文库+ // @namespace https://greasyfork.org/users/667968-pyudng // @version 2.alpha.18 // @description 轻小说文库全方位体验改善,涵盖阅读、下载、书架、推荐、书评、账号、页面个性化等各种方面,你能想到的这里都有。没有?欢迎提出你的点子。 // @author PY-DNG // @license GPL-3.0-or-later // @homepageURL https://greasyfork.org/scripts/539514 // @supportURL https://greasyfork.org/scripts/539514/feedback // @match http*://*.wenku8.com/* // @match http*://*.wenku8.net/* // @match http*://*.wenku8.cc/* // @require data:application/javascript,window.setImmediate%20%3D%20window.setImmediate%20%7C%7C%20((f%2C%20...args)%20%3D%3E%20window.setTimeout(()%20%3D%3E%20f(args)%2C%200))%3B // @require https://update.greasyfork.org/scripts/456034/1637068/Basic%20Functions%20%28For%20userscripts%29.js // @require https://update.greasyfork.org/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_listValues // @grant GM_deleteValue // @grant GM_addValueChangeListener // @grant GM_removeValueChangeListener // @grant GM_log // @grant GM_addElement // @grant GM_xmlhttpRequest // @grant GM_setClipboard // ==/UserScript== /* 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: '请选择', InputMustBeFloat: '请输入数值!', }, Tabs: { ModuleSettings: '模块设置', About: '关于', AboutTab: '关于脚本', FAQ: '常见问题', }, About: { Version: `版本: ${ GM_info.script.version }`, Author: `作者: ${ GM_info.script.author }`, Homepage: `主页: <a href="${ GM_info.script.homepageURL }" target="_blank">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} 条新内容', FloorUpdateError: '楼层更新时发生错误', FloorUpdateErrorCaption: '请检查网络是否通畅,必要时可导出调试信息反馈给开发者', }, 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: '已复制', }, BookDetails: { ShowDetails: '本书数据', DataNames: { 'DayHitsCount': '日点击量', 'TotalHitsCount': '总点击量', 'PushCount': '推书次数', 'FavCount': '收藏人数', }, Dialog: { Title: '书籍数据 - {Name}', Ok: '确定', Cancel: '复制', }, }, 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: '累计自动推书: ', TotalVotes: '已分配的总票数: ', TotalBooks: '总书籍数量: ', ConfirmRemove: { Title: '从自动推书中移除书籍', Message: '确实要将 {Name} 从自动推书中移除吗?移除后,以前的推书记录也将一同被删除。', Ok: '确定', Cancel: '还是算了', }, }, Settings: { Title: '自动推书', Configuration: '自动推书配置', Configure: '编辑', Enabled: '启用自动推书', EnabledCaption: '关闭后将不再每日自动推书、不显示相关UI,但推书配置和记录仍将保留', } }, ReviewCollection: { CollectionTitle: '书评收藏', Add: '收藏书评', Remove: '取消收藏书评', Added: '已添加到书评收藏', Removed: '已取消收藏此书评', HasNewFloors: '<span style="font-weight: bolder;">[有更新]</span>', Settings: { Title: '书评收藏', Enabled: '启用书评收藏', EnabledCaption: '关闭后,将不再显示相关UI,但收藏的书评仍将保留', ListPosition: '首页收藏列表放置位置', ListPositionCaption: '在哪里显示收藏的书评', ListPositionLeft: '左侧', ListPositionRight: '右侧', OpenLastPage: '打开书评最后一页', OpenLastPageCaption: '从首页的书评收藏列表中打开书评时,直接跳转到书评最后一页', NewFloorCheckInterval: '检查楼层更新时间间隔(单位:小时)', NewFloorCheckIntervalCaption: '每过这么长时间就检查一次收藏的书评是否存在新楼层,如果有就在首页提示;设置为0则每次打开新页面时都检查,设置为负数则永不检查(禁用此功能)', AddOnReply: '回复的同时收藏', AddOnReplyCaption: '当对书评发表回复时,自动将该书评加入收藏', AutoRemoveTimeout: '未查看书评自动移除收藏时间(单位:天)', AutoRemoveTimeoutCaption: '当收藏书评连续这么长时间未打开查看过时,自动将其移除收藏;设置为负数禁用此功能' }, }, 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: '图片遮罩层不透明度', MaskBlur: '对图片遮罩层启用高斯模糊', Color: '颜色', }, }, OpenLastPage: { OpenLastPageButton: '[打开尾页]', }, Blocking: { BlockUser: '屏蔽用户', UnBlockUser: '解除屏蔽', UserBlocked: '该用户已被屏蔽', BlockBook: '屏蔽本书', UnBlockBook: '解除屏蔽', BlockedBook: '已屏蔽 {Name}', UnBlockedBook: '已解除屏蔽 {Name}', BookBlocked: `[${ GM_info.script.name }]本书已被屏蔽`, BookBlockedTip: `<div style="text-align:center;">[${ GM_info.script.name }]本书已被屏蔽<br>双击临时显示本书</div>`, Settings: { Label: '屏蔽功能', Enabled: '启用屏蔽功能', EnabledCaption: '停用后,将同时不再展示屏蔽按钮等界面', BlockList: '屏蔽列表管理', BlockListEdit: '编辑', }, UI: { Title: '屏蔽列表管理', TimeAdded: '加入屏蔽时间: ', ConfirmRemove: { Title: '确认移除', Message: '确定要从屏蔽列表中移除 {Name} 吗?', Ok: '移除', Cancel: '不移除', }, }, }, Reader: { SideButton: '样式调节', UI: { Title: '阅读器样式调节', Enabled: '启用样式调节', EnabledCaption: '启用后将覆盖文库自带样式调节', FontFamily: '字体样式', FontFamilyCaption: '可自行输入字体名称', FontSize: '字体大小', FontSizeSuffix: 'px', Color: '字体颜色', ColorCaption: '同时应用于标题和正文', FontOptions: [{ label: '宋体', value: '宋体', }, { label: '新细明体', value: '新细明体', }, { label: '微软雅黑', value: 'Microsoft Yahei, "微软雅黑"', }, { label: '黑体', value: '黑体', }, { label: '楷体', value: '楷体', }], }, }, UBBEditor: { DraftButton: '草稿/历史', DraftEmpty: '尚无保存的草稿', DraftEmptyCaption: '在书评编辑框中编写内容后会自动保存草稿,之后就可以点击草稿/历史按钮调用啦', DraftSwitched: '已读取草稿', }, }, 'zh-TW': { 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: '請選擇', InputMustBeFloat: '請輸入數值!', }, Tabs: { ModuleSettings: '模組設定', About: '關於', AboutTab: '關於腳本', FAQ: '常見問題', }, About: { Version: `版本: ${ GM_info.script.version }`, Author: `作者: ${ GM_info.script.author }`, Homepage: `主頁: <a href="${ GM_info.script.homepageURL }" target="_blank">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} 條新內容', FloorUpdateError: '樓層更新時發生錯誤', FloorUpdateErrorCaption: '請檢查網路是否通暢,必要時可匯出除錯資訊回饋給開發者', }, 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: '前往書架', }, }, BookDetails: { ShowDetails: '本書數據', DataNames: { 'DayHitsCount': '日點擊量', 'TotalHitsCount': '總點擊量', 'PushCount': '推書次數', 'FavCount': '收藏人數', }, Dialog: { Title: '書籍數據 - {Name}', Ok: '確定', Cancel: '複製', }, }, 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: '累計自動推書: ', TotalVotes: '已分配的總票數: ', TotalBooks: '總書籍數量: ', ConfirmRemove: { Title: '從自動推書中移除書籍', Message: '確實要將 {Name} 從自動推書中移除嗎?移除後,以前的推書記錄也將一同被刪除。', Ok: '確定', Cancel: '還是算了', }, }, Settings: { Title: '自動推書', Configuration: '自動推書設定', Configure: '編輯', Enabled: '啟用自動推書', EnabledCaption: '關閉後將不再每日自動推書、不顯示相關UI,但推書設定和記錄仍將保留', } }, ReviewCollection: { CollectionTitle: '書評收藏', Add: '收藏書評', Remove: '取消收藏書評', Added: '已新增到書評收藏', Removed: '已取消收藏此書評', HasNewFloors: '<span style="font-weight: bolder;">[有更新]</span>', Settings: { Title: '書評收藏', Enabled: '啟用書評收藏', EnabledCaption: '關閉後,將不再顯示相關UI,但收藏的書評仍將保留', ListPosition: '首頁收藏列表放置位置', ListPositionCaption: '在哪裡顯示收藏的書評', ListPositionLeft: '左側', ListPositionRight: '右側', OpenLastPage: '開啟書評最後一頁', OpenLastPageCaption: '從首頁的書評收藏列表中開啟書評時,直接跳轉到書評最後一頁', NewFloorCheckInterval: '檢查樓層更新時間間隔(單位:小時)', NewFloorCheckIntervalCaption: '每過這麼長時間就檢查一次收藏的書評是否存在新樓層,如果有就在首頁提示;設置為0則每次開啟新頁面時都檢查,設置為負數則永不檢查(停用此功能)', AddOnReply: '回覆的同時收藏', AddOnReplyCaption: '當對書評發表回覆時,自動將該書評加入收藏', AutoRemoveTimeout: '未查看書評自動移除收藏時間(單位:天)', AutoRemoveTimeoutCaption: '當收藏書評連續這麼長時間未開啟查看過時,自動將其移除收藏;設置為負數停用此功能' }, }, 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: '圖片遮罩層不透明度', MaskBlur: '對圖片遮罩層啟用高斯模糊', Color: '顏色', }, }, OpenLastPage: { OpenLastPageButton: '[開啟尾頁]', }, Blocking: { BlockUser: '封鎖使用者', UnBlockUser: '解除封鎖', UserBlocked: '該使用者已被封鎖', BlockBook: '封鎖本書', UnBlockBook: '解除封鎖', BlockedBook: '已封鎖 {Name}', UnBlockedBook: '已解除封鎖 {Name}', BookBlocked: `[${ GM_info.script.name }]本書已被封鎖`, BookBlockedTip: `<div style="text-align:center;">[${ GM_info.script.name }]本書已被封鎖<br>雙擊臨時顯示本書</div>`, Settings: { Label: '封鎖功能', Enabled: '啟用封鎖功能', EnabledCaption: '停用後,將同時不再展示封鎖按鈕等介面', BlockList: '封鎖列表管理', BlockListEdit: '編輯', }, UI: { Title: '封鎖列表管理', TimeAdded: '加入封鎖時間: ', ConfirmRemove: { Title: '確認移除', Message: '確定要從封鎖列表中移除 {Name} 嗎?', Ok: '移除', Cancel: '不移除', }, }, }, Reader: { SideButton: '樣式調節', UI: { Title: '閱讀器樣式調節', Enabled: '啟用樣式調節', EnabledCaption: '啟用後將覆蓋文庫自帶樣式調節', FontFamily: '字型樣式', FontFamilyCaption: '可自行輸入字型名稱', FontSize: '字型大小', FontSizeSuffix: 'px', Color: '字型顏色', ColorCaption: '同時應用於標題和內文', FontOptions: [{ label: '宋體', value: '宋體', }, { label: '新細明體', value: '新細明體', }, { label: '微軟正黑體', value: 'Microsoft JhengHei, "微軟正黑體"', }, { label: '黑體', value: '黑體', }, { label: '楷體', value: '楷體', }], }, }, UBBEditor: { DraftButton: '草稿/歷史', DraftEmpty: '尚無儲存的草稿', DraftEmptyCaption: '在書評編輯框中編寫內容後會自動儲存草稿,之後就可以點擊草稿/歷史按鈕呼叫啦', DraftSwitched: '已讀取草稿', }, }, get ['zh-HK']() { return CONST.TextAllLang['zh-TW']; }, }, /** * @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: { // 脚本自检用常量 Doctor: { // 单模块最大存储大小 MaximumStorageSize: 1024 * 32, }, // 用于各类解锁时,取得DOM等模板所用的未锁定书籍的aid UnlockTemplateAID: 1, // 最长存储日志页面数量 DefaultLogMaxPage: 10, // 最长存储日志条数 DefaultLogMaxLength: 30, // 最长存储错误数量 DefaultErrorMaxLength: 10, // 板块折叠:消失的板块所对应的折叠记录在连续观察到几次从文档中消失后清除 RemoveBlockFoldingCount: 10, // 自动推书:其他标签页存活检测 最长更新时间间隔 AutovoteActiveTimeout: 10 * 1000, // 书评楼层自动刷新间隔 ReviewAutoRefreshInterval: 20 * 1000, // 默认书评收藏 BuiltinReviewCollection: [{ rid: 298520, name: '[轻小说文库+] 脚本反馈站', record: { top: 0, has_new: true, last_check: 0, }, }, { rid: 228884, name: '文库导航姬', record: { top: 0, has_new: true, last_check: 0, }, }, { rid: 282295, name: '文库导航 / 中转站', record: { top: 0, has_new: true, last_check: 0, }, }], // 书评图片重试缩放间隔 ReviewResizeInterval: 500, // 自适应高度编辑器的最大和最小高度 EditorHeight: { Min: 150, Max: 500, }, // 屏蔽书籍临时展示时长 BlockingBookTempShowTime: 5000, // 书评收藏楼层更新自动检测最短时间间隔 ReviewUpdateMinCheckInterval: 10 * 60 * 1000, // 书评草稿保存最大条目数量 UBBEditorMaximumDraft: 30, }, }; const functions = { utils: { /** @typedef {Awaited<ReturnType<typeof functions.utils.func>>} utils */ func() { /** @type {typeof window} */ const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; // 记录开始加载时间 const load_start = Date.now(); // 当日志模块加载完毕时,记录日志 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, in %c${ Date.now() - load_start }%cms`, 'color: orange;', 'color: unset;', ); }); /** * 获取当前页面的语言:繁体中文/简体中文 * @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]; } } /** * 将给定html转化为元素,注意这里使用了.innerHTML,因此<script>不会执行 * 所给html应仅包含一个根层级元素 * @param {string} html * @returns {HTMLElement} */ function html2elm(html) { return $$CrE({ tagName: 'div', props: { innerHTML: html, } }).firstElementChild; } /** * 向输入框的当前光标位置中插入文本 * @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'); } } /** @typedef {typeof FunctionLoader._types.oFunc} oFunc */ /** @typedef {InstanceType<typeof FunctionLoader.FuncPool>} FuncPool */ /** * 新建一个FuncPool加载oFuncs,oFuncs以对象格式书写(而非标准的数组格式) * 返回 { promise, pool }, promise将会在加载完毕时resolve,pool为加载时创建的新FuncPool * @param {Record<string, Omit<oFunc, 'id'>>} oFuncs * @param {Record<'GM_getValue' | 'GM_setValue' | 'GM_listValues' | 'GM_deleteValue', function>} [GM_funcs={}] * @returns {{ promise: Promise, pool: FuncPool }} */ function loadFuncInNewPool(oFuncs, 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 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) ? structuredClone(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; } /** * 深度比较两个值是否相等(值相等,不要求引用相等) * 本函数由deepseek编写的代码再编辑而成 * @param {*} value1 - 第一个值 * @param {*} value2 - 第二个值 * @param {boolean} [sorting=true] - 是否考虑顺序(数组元素顺序、对象属性顺序等) * @returns {boolean} - 如果两个值深度相等则返回true,否则返回false */ function deepEqual(value1, value2, sorting = true) { // 处理基本类型的快速比较 if (value1 === value2) return true; // 处理null和undefined if (value1 == null || value2 == null) { return value1 === value2; } // 处理NaN if (Number.isNaN(value1) && Number.isNaN(value2)) return true; // 检查类型是否一致 if (typeof value1 !== typeof value2) return false; // 处理基本类型(经过前面的比较,这里肯定不相等) if (typeof value1 !== "object") return false; // 处理数组 if (Array.isArray(value1)) { if (!Array.isArray(value2)) return false; if (value1.length !== value2.length) return false; // 如果考虑顺序,直接按顺序比较 if (sorting) { for (let i = 0; i < value1.length; i++) { if (!deepEqual(value1[i], value2[i], sorting)) { return false; } } return true; } // 如果不考虑顺序,需要检查每个元素是否在另一个数组中存在 const arr2Copy = [...value2]; for (const item1 of value1) { let found = false; for (let j = 0; j < arr2Copy.length; j++) { if (deepEqual(item1, arr2Copy[j], sorting)) { arr2Copy.splice(j, 1); found = true; break; } } if (!found) return false; } return true; } // 处理对象 if (typeof value1 === "object" && typeof value2 === "object") { const keys1 = Object.keys(value1); const keys2 = Object.keys(value2); // 检查key数量 if (keys1.length !== keys2.length) return false; // 如果考虑顺序,先检查key顺序是否一致 if (sorting) { for (let i = 0; i < keys1.length; i++) { if (keys1[i] !== keys2[i]) return false; } } else { // 如果不考虑顺序,检查key集合是否相同 const keys1Set = new Set(keys1); for (const key of keys2) { if (!keys1Set.has(key)) return false; } } // 递归比较每个属性值 for (const key of keys1) { if (!deepEqual(value1[key], value2[key], sorting)) { return false; } } return true; } // 其他情况(如Date、RegExp等)可以在这里添加特殊处理 // 目前简单转为字符串比较 return String(value1) === String(value2); } /** * 调用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 * @param {number} [retry=3] - 失败重试次数 * @returns {Promise<Blob>} */ function _requestBlob(url, retry=3) { const { promise, resolve } = Promise.withResolvers(); GM_xmlhttpRequest({ method: 'GET', url, responseType: 'blob', onload(response) { resolve(response.response); }, onerror(err) { retry-- && _requestBlob(url, retry); } }); 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; } /** * 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, // 功能相关 insertText, html2elm, loadFuncInNewPool, defaultedGet, requestText, requestDocument, requestBlob, getModuleDir, // 管理器 ProgressManager, // 算法相关 toQueued, serializeFormData, zfill, htmlEncode, randstr, randint, deepEqual, }; } }, 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, defaults to log the error * @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 {(typeof LogLevel)[keyof LogLevel]} */ function getLoglevel() { const saved_level = GM_getValue('loglevel', LogLevel.Message); const script_debug = require('debugging')?.script_debug || false; return script_debug ? LogLevel.Debug : saved_level; } /** * 设置日志输出级别 * @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, }; } }, doctor: { desc: '用于脚本自检bug并提供修复功能等', dependencies: ['debugging', 'logger'], func() { /** @type {logger} */ const logger = require('logger'); /** @type {debugging} */ const debugging = require('debugging'); /** * 代表一条测试项目 * @typedef {Object} Test * @property {string} [desc] - 测试的描述 * @property {() => TestResult} func - 测试函数,输出测试是否通过 */ /** * 代表一条测试结果 * @typedef {{ result: any, pass: boolean }} TestResult */ /** * @satisfies {Record<string, Test>} */ const tests = { 'lang-struct': { desc: '检测各语言包文本常量是否结构类型一致', func() { const T = CONST.TextAllLang; const results = Object.keys(T).filter(key => key !== 'DEFAULT').map((key, i, keys) => { if (i + 1 >= keys.length) { return null; } const key2 = keys[i+1]; const same = isSameStructure(T[key], T[key2]); return { key1: key, key2, same, }; }).filter(result => result !== null); return { result: results, pass: results.every(r => r.same), } } }, 'huge-storage': { desc: '检测是否存在过大的存储数据', func() { /** * 代表一条存储项目的大小 * @typedef {Object} StorageSize * @property {number} size - 字节数大小 * @property {boolean} oversize - 是否过大 * @property {number} ratio - 相对于最大限额的比例 */ /** @type {string[]} */ const keys = GM_listValues(); const sizes = keys.reduce( /** * @param {Record<string, StorageSize>} sizes * @param {string} key * @returns {Record<string, StorageSize>} */ (sizes, key) => { const val = GM_getValue(key); const json = JSON.stringify(val); const blob = new Blob([ json ], { type: 'text/plain' }); const size = blob.size; const MaxSize = CONST.Internal.Doctor.MaximumStorageSize; sizes[key] = { size, oversize: size > MaxSize, ratio: MaxSize > 0 ? size / MaxSize : Infinity, }; return sizes; }, {} ); return { result: sizes, pass: Object.values(sizes).every(size => !size.oversize), }; } } }; const results = runTests(tests); const all_passed = Object.values(results).every(r => r.pass); debugging.script_debug && logger.log('Debug', 'doctor test results:', results); all_passed ? logger.log('Info', 'doctor: all tests passed') : logger.error('Error', 'doctor: test(s) failed'); /** * 执行测试并给出测试结果 * @template {Record<string, Test>} T * @param {T} tests * @returns {Record<keyof T, TestResult>} */ function runTests(tests) { return Object.entries(tests).reduce((result, [key, test]) => Object.assign(result, { [key]: test.func() }), {}); } /** * 深度检查两个对象的结构类型是否一致 * @param {object} obj1 第一个对象 * @param {object} obj2 第二个对象 * @returns {boolean} 如果结构类型完全一致则返回 true,否则返回 false */ function isSameStructure(obj1, obj2) { // 处理基本类型情况 if (typeof obj1 !== typeof obj2) { return false; } // 处理非对象类型(包括 null) if (typeof obj1 !== 'object' || obj1 === null || obj2 === null) { return typeof obj1 === typeof obj2; } // 处理数组 if (Array.isArray(obj1) || Array.isArray(obj2)) { if (!Array.isArray(obj1) || !Array.isArray(obj2)) { return false; } if (obj1.length !== obj2.length) { return false; } // 检查数组元素类型 for (let i = 0; i < obj1.length; i++) { if (!isSameStructure(obj1[i], obj2[i])) { return false; } } return true; } // 获取两个对象的所有属性名 const keys1 = Object.keys(obj1); const keys2 = Object.keys(obj2); // 检查属性数量是否相同 if (keys1.length !== keys2.length) { return false; } // 检查所有属性名是否相同且类型一致 for (const key of keys1) { if (!keys2.includes(key)) { return false; } if (!isSameStructure(obj1[key], obj2[key])) { return false; } } return true; } } }, 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 {boolean} loading - 按钮是否置于"加载中"状态 * @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" :loading="!!button.loading" 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 { /** * Read-only button instances * @type {Button[]} */ get buttons() { return Vue.toRaw(instance.buttons).map(btn => Object.assign({}, btn)) }, registerButton, removeButton, updateButton, hasButton, }; } }, history: { desc: '封装、管理浏览历史对象', /** @typedef {Awaited<ReturnType<typeof functions.history.func>>} history */ func() { let cur_url = location.href; /** * 基本等效于{@link History.prototype.pushState} * @param {Object} state - 任意可序列化的对象 * @param {string} unused - 由于历史原因存在此参数,传递一个空字符串是安全的 * @param {string} [url] - 地址,可以是绝对地址或是相对地址;绝对地址需要和当前网页同源,相对地址将会基于当前页面地址解析;若没有指定,则为当前文档的地址不变 */ function pushState(state, unused, url) { url = url ?? location.href; history.pushState(state, unused, url); cur_url = new URL(url, location.href).href; } /** * 基本等效于{@link History.prototype.replaceState} * @param {Object} state - 任意可序列化的对象 * @param {string} unused - 由于历史原因存在此参数,传递一个空字符串是安全的 * @param {string} [url] - 地址,可以是绝对地址或是相对地址;绝对地址需要和当前网页同源,相对地址将会基于当前页面地址解析;若没有指定,则为当前文档的地址不变 */ function replaceState(state, unused, url) { url = url ?? location.href; history.replaceState(state, unused, url); cur_url = new URL(url, location.href).href; } /** * 监听历史记录回退事件 * @param {(evt: PopStateEvent & { old_url: string, new_url: string }) => any} callback */ function onPopstate(callback) { $AEL(window, 'popstate', /** @param {PopStateEvent} e */ e => { const evt = new Proxy(e, { get(target, p, receiver) { return { // 额外提供的值 old_url: cur_url, new_url: location.href, // 由于套了层proxy,直接调用会出错,在这里手动绑定一下this preventDefault: target.preventDefault.bind(target), stopPropagation: target.stopPropagation.bind(target), stopImmediatePropagation: target.stopImmediatePropagation.bind(target), } [p] ?? target[p]; }, }); callback(evt); cur_url = location.href; return evt; } ); } return { pushState, replaceState, onPopstate }; } }, 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> <!-- 浮点数类型: 输入框 --> <p-number v-else-if="item.type === 'number'" 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)" ></p-number> <!-- 数字范围类型:滑块 --> <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> <span v-else>Warning: item.type invalid ({{ item.type }})</span> <!-- 浮动提示 --> <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'], data() { return { picker_visible: false, } }, template: ` <q-input v-model="color" :rules="['anyColor']" @focus="() => picker_visible = true" > <template v-slot:prepend> <div :style="{ width: '1em', height: '1em', background: color, borderRadius: '30%', cursor: 'pointer' }" @click="() => picker_visible = true" ></div> </template> <template v-slot:append> <q-icon name="colorize" class="cursor-pointer"> <q-popup-proxy cover transition-show="scale" transition-hide="scale" v-model="picker_visible"> <q-color v-model="color" default-view="palette"></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; } }, }); // 浮点数类型 app.component('p-number', { name: 'PNumber', props: ['modelValue'], emits: ['update:modelValue'], template: ` <q-input v-model="number" :rules="[val => /^-?\\d+(\\.\\d+)?$/.test(val) || ${ escJsStr(CONST.Text.Settings.Component.InputMustBeFloat, "'") }]" ></q-input> `, computed: { number: { get() { return this.modelValue.toString(); }, set(number) { number = parseFloat(number); isNaN(number) || this.$emit('update:modelValue', number); }, }, }, }); // 挂载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, }; }, }, storageupdater: { desc: '管理和更新模块以及脚本存储', dependencies: ['debugging'], /** @typedef {Awaited<ReturnType<typeof functions.storageupdater.func>>} storageupdater */ func() { /** @type {debugging} */ const debugging = require('debugging'); /** * 执行更新的函数,接受旧版存储作为参数,返回更新后的新版存储 * @callback updater * @param {Object} config * @returns {Object} */ /** * 脚本管理器提供的GM_*存储函数 * @typedef {Object} GM_funcs * @property {(key: string, defaultValue: any) => any} GM_getValue * @property {(key: string, value: any) => void} GM_setValue * @property {() => string[]} GM_listValues * @property {(key: string) => void} GM_deleteValue */ /** * 根据提供的更新器函数和当前存储的值更新存储值到最新版本 * 当未设置版本号时,默认版本号为0 * @param {updater[]} updaters - 更新函数数组,第0个函数为从第0版更新到第1版的更新器,第1个函数为从第1版更新到第2版的更新器,以此类推 * @param {GM_funcs} GM_funcs - 用于操作存储空间的GM_*存储函数 * @param {string} version_key - 存储版本号的键,默认为"config_version" */ function update(updaters, GM_funcs, version_key='config_version') { const { GM_getValue, GM_setValue, GM_listValues, GM_deleteValue } = GM_funcs; const max_version = updaters.length; for (let cur_version = GM_getValue(version_key) ?? 0; cur_version < max_version; cur_version++) { // 获取当前存储和updater const updater = updaters[cur_version]; const storage = getStorageObj(GM_funcs); // 执行updater,若出现错误则停止更新流程 let has_error = false; const updated_storage = debugging.callWithErrorHandling(updater, null, [storage], err => { has_error = true; }); if (has_error) { break; } updated_storage[version_key] = cur_version + 1; // 本轮更新完成,存储更新结果 applyStorageObj(updated_storage); } function getStorageObj(GM_funcs) { const { GM_getValue, GM_listValues } = GM_funcs; return GM_listValues().reduce((obj, key) => Object.assign(obj, { [key]: GM_getValue(key) }), {}); } function applyStorageObj(storage) { const { GM_getValue, GM_setValue, GM_listValues, GM_deleteValue } = GM_funcs; GM_listValues().forEach(key => GM_deleteValue(key)); Object.entries(storage).forEach(([key, val]) => GM_setValue(key, val)); } } return { update }; } }, _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, *) .blocktitle) {background: #333333;}.plus-darkmode :is(.main.m_top, .nav, .navinner, .navlist, .nav li, :is(#left, #right, #centers, *) .blocktitle > :is(.txt, .txtr)) {background: #383838;}.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);}' }, // 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', 'history'], 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'); /** @type {history} */ const history = require('history'); // 如果是发评论返回的提示页面,不继续运行 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, doc)); } /** * 将楼层DOM结构解析为标准楼层对象 * 仅可解析未经修改的Wenku自带DOM * @param {HTMLTableElement} table - 楼层的table根元素 * @param {Document} doc - 所在Document文档 * @returns {Floor} */ function parse(table, doc) { const element = parseElement(table, doc); const data = parseData(element, doc); return { element, data }; } /** * 从楼层DOM结构解析标准楼层元素对象 * 仅可解析未经修改的Wenku自带DOM * @param {HTMLTableElement} table - 楼层的table根元素 * @param {Document} doc - 所在Document文档 * @returns {FloorElement} */ function parseElement(table, doc) { 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 - 楼层元素对象 * @param {Document} doc - 所在Document文档 * @returns {FloorData} */ function parseData(element, doc) { 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 = getFloorUrl(link_elm); 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 }; /** @param {HTMLAnchorElement} link_elm */ function getFloorUrl(link_elm) { const obj_url = new URL(link_elm.href); !/\d+/.test(obj_url.searchParams.get('page')) && obj_url.searchParams.set('page', $(doc, '#pagelink > strong').innerText.trim()); return obj_url.href; } } // 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, error: (err: Error) => void }>} */ const UI = { loading: { start() { Quasar.Loading.show({ message: CONST.Text.Review.FloorManager.UpdatingFloors }); }, end() { Quasar.Loading.hide(); }, error(err) { Quasar.Loading.hide(); Quasar.Notify.create({ type: 'error', message: CONST.Text.Review.FloorManager.FloorUpdateError, caption: CONST.Text.Review.FloorManager.FloorUpdateErrorCaption, group: 'review.update_floor_error' }); require('debugging', true).then( /** @param {debugging} debugging */ debugging => debugging.saveError({ type: 'fetch', error: err, info: null, }) ) } }, 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' }); }, error(err) { Quasar.Notify.create({ type: 'error', message: CONST.Text.Review.FloorManager.FloorUpdateError, caption: CONST.Text.Review.FloorManager.FloorUpdateErrorCaption, group: 'review.update_floor_error' }); require('debugging', true).then( /** @param {debugging} debugging */ debugging => debugging.saveError({ type: 'fetch', error: err, info: null, }) ) } } }; /** * 获取一个评论页面,并解析 * @param {number} rid * @param {number | 'last'} [page] * @returns {Promise<{ floors: Floor[], pagelink: HTMLDivElement }>} */ function fetch(rid, page, retry=2) { const { promise, resolve, reject } = Promise.withResolvers(); try { utils.requestDocument({ method: 'GET', url: `/modules/article/reviewshow.php?rid=${rid}&page=${page ?? 1}`, onerror: onError, }).then(doc => { const floors = parser.parseAll(doc); const pagelink = $(doc, '#pagelink'); resolve({ floors, pagelink }); }); } catch(err) { onError(err); } return promise; async function onError(err) { /** @type {logger} */ const logger = await require('logger', true); if (retry-- > 0) { logger.warn('Warn', 'review.FloorManager.updater.fetch: Retrying...'); await fetch(rid, page, retry).then(result => resolve(result)).catch(err => reject(err)); } else { logger.warn('Error', 'review.FloorManager.updater.fetch: Maximum error retry attempts reached'); reject(err); } } } /** * 从文库服务器获取当前书评页面的最新版本,并更新到页面中,同时也更新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; /** @type {Floor[]} */ let new_floors; /** @type {HTMLDivElement} */ let new_pagelink; await (fetch(rid, page).then(({ floors, pagelink }) => { new_floors = floors; new_pagelink = pagelink; }).catch(err => { UI[ui].error(err); throw err; })); // 旧楼层列表比新楼层列表长时,去除旧楼层尾部多出来的楼层 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'], /** @typedef {Awaited<ReturnType<typeof pool_funcs.floorjump.func>>} floorjump */ 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; } // 尝试跳转,当目标yid楼层在页面内时会跳转成功 // 前面判断过a.hash符合yid\d+的格式,可以直接match取值 const yid = parseInt(a.hash.match(/\d+/)[0], 10); const success = jump(yid); success && e.preventDefault(); }); /** * 跳转到页面内某楼层,成功返回true,失败返回false * @param {number} yid - 跳转目标楼层yid * @param {boolean} [pushState=true] - 是否添加浏览历史记录,默认为true * @returns {boolean} */ function jump(yid, pushState = true) { // 检查目标楼层是否在页面内 /** @type {Floor} */ const floor = floors.find(floor => floor.data.yid === yid); if (!floor) { return false; } // 检查通过,跳转 floor.element.root.scrollIntoView({ behavior: 'smooth' }); // 添加浏览历史记录 if (pushState) { const url = new URL(floor.data.url); const path = `${url.pathname}${url.search}${url.hash}`; history.pushState(null, '', path); } return true; } // 注册设置项 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); return { get enabled() { return GM_getValue('jump'); }, set enabled(val) { GM_setValue('jump', val); }, jump, }; } }, pagejump: { desc: '点击右下角页码切换,直接页面内更新,而不是重建加载页面到该页码', dependencies: ['FloorManager'], params: ['GM_setValue', 'GM_getValue', 'GM_addValueChangeListener'], /** @typedef {Awaited<ReturnType<typeof pool_funcs.pagejump.func>>} pagejump */ 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'); }); // 注册设置项 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); return { get enabled() { return GM_getValue('jump'); }, set enabled(val) { GM_setValue('jump', val); }, }; } }, popstate: { desc: '处理浏览器历史记录回退', dependencies: ['floorjump', 'pagejump'], func() { /** @type {FloorManager} */ const FloorManager = pool.require('FloorManager'); /** @type {floorjump} */ const floorjump = pool.require('floorjump'); /** @type {pagejump} */ const pagejump = pool.require('pagejump'); history.onPopstate(async e => { const old_url = new URL(e.old_url); const new_url = new URL(e.new_url); const same_review = old_url.searchParams.get('rid') === new_url.searchParams.get('rid'); const same_page = same_review && old_url.searchParams.get('page') === new_url.searchParams.get('page'); const same_floor = same_page && old_url.hash === new_url.hash; // 页面、楼层都未改变,不执行任何操作 if (same_floor) { return; } // 同一页面内不同楼层,直接滚动至该楼层 if (same_page) { // 只有当url中确实指定了yid时才跳转,否则无楼可跳,此时让浏览器默认行为处理即可 const str_yid = new_url.hash.match(/\d+/)?.[0]; if (floorjump.enabled && str_yid) { floorjump.jump(parseInt(str_yid, 10), false); e.preventDefault(); } return; } // 同一书评不同页面,动态更新到该页面,同时如有指定楼层,跳转到该楼层 if (same_review) { // 页面更新 const page = parseInt(new_url.searchParams.get('page') ?? '0', 10); pagejump.enabled ? await FloorManager.updater.update('loading', page, false, 'none') : location.reload(); // 楼层跳转 const str_yid = new_url.hash.match(/\d+/)?.[0]; // 这里注意楼层更新后会有一个QLoading加载遮罩渐变消失的动画,期间body不可滚动,需延迟一会等待动画完毕再进行跳转 floorjump.enabled && str_yid && setTimeout(() => floorjump.jump(parseInt(str_yid, 10), false), 500); return; } // 不同书评,刷新页面 location.reload(); }); }, }, 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); let editing = false; /** * 将楼层的编辑按钮(如果有)点击后改为在页面内编辑,而不是打开一个新页面 * @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(); // 防止重复编辑 if (editing) { return; } editing = true; // 获取编辑框部分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'), // Material Icons `<style>${ GM_getResourceText('quasar-icon') || GM_getResourceText('quasar-icon-bak') }</style>`, // 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); // 按下Esc时关闭弹窗 $AEL(doc, 'keyup', e => e.code === 'Escape' && dialog.hide()); // 但是在编辑框内不要按下直接Esc就关闭,因为有可能是在和输入法交互 // 记录:如果正在和输入法交互,或者过去250毫秒内和输入法交互过,就忽略此次Escape按键 let is_composing = false, last_composed = 0; const pcontent = $(doc, '#pcontent'); const ptitle = $(doc, '#ptitle'); [pcontent, ptitle].filter(elm => !!elm).forEach(elm => { $AEL(elm, 'compositionstart', e => is_composing = true); $AEL(elm, 'compositionend', e => { is_composing = false; last_composed = Date.now(); }); $AEL(elm, 'keyup', e => (is_composing || Date.now() - last_composed < 250) && e.stopPropagation()); }); }, ]] }); // 在 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', }, }).onDismiss(() => editing = false); (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: '页面样式修复增强', detectDom: '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'], params: ['GM_setValue', 'GM_getValue'], /** @typedef {Awaited<ReturnType<typeof functions.ubbeditor.func>>} ubbeditor */ async func(GM_setValue, GM_getValue) { /** @type {utils} */ const utils = require('utils'); /** @typedef {{ content: string, title: string, id: string }} Draft */ GM_getValue = utils.defaultedGet({ /** @type {Draft[]} */ drafts: [] }, GM_getValue) // 自动将修复与增强功能应用于已知的页面内自带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: 0.25em; 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`; }) ), // 菜单按钮title升级为tiptitle detectDom(form, '#UBB_Menu').then( /** @param {HTMLDivElement} pcontent */ menu => detectDom({ root: menu, selector: '.UBB_MenuItem', async callback(item) { if (!item.hasAttribute('title')) { return; } if (item.ownerDocument !== utils.window.document) { return; } const title = item.getAttribute('title'); item.removeAttribute('title'); /** @type {mousetip} */ const mousetip = await require('mousetip', true); mousetip.set(item, title); } }) ), // 草稿功能 detectDom(form, '#pcontent').then( /** @param {HTMLTextAreaElement} pcontent */ async pcontent => { // 当#pcontent出现时,如有#ptitle也应已经出现 // 注:本段代码编写时,尚未在脚本任意位置实现“恢复/添加#ptitle元素”的功能 const ptitle = $(form, '#ptitle'); // 随机生成全局唯一id let id = utils.randstr(16, true, GM_getValue('drafts').map(d => d.id)); // 自动保存草稿 $AEL(pcontent, 'input', e => saveDraft()); ptitle && $AEL(ptitle, 'input', e => saveDraft()); // 添加草稿UI const menu = await detectDom(form, '#UBB_Menu'); $(menu, 'div[style="clear: both;"]').before($$CrE({ tagName: 'div', props: { innerHTML: 'history', title: CONST.Text.UBBEditor.DraftButton, }, classes: ['UBB_MenuItem'], styles: { fontFamily: '"Material Icons"', color: 'var(--q-primary)', fontSize: '1.3em', display: 'flex', justifyContent: 'center', alignItems: 'center', }, listeners: [['click', (() => { // 每次点击,切换到下一条目 let i = -1; return e => { // 获取所有保存的草稿,并颠倒顺序以从近到远排列 /** @type {Draft[]} */ const drafts = GM_getValue('drafts').reverse(); if (!drafts.length) { Quasar.Notify.create({ type: 'info', message: CONST.Text.UBBEditor.DraftEmpty, caption: CONST.Text.UBBEditor.DraftEmptyCaption, group: 'ubbeditor.draft.empty', }); return; } // 切换条目内容 i >= drafts.length - 1 && (i = -1); const draft = drafts[++i]; pcontent.value = draft.content; ptitle && (ptitle.value = draft.title); // 切换id,以达成编辑条目的效果 id = draft.id; Quasar.Notify.create({ type: 'success', message: CONST.Text.UBBEditor.DraftSwitched, group: 'ubbeditor.draft.empty', }); } }) ()]] })); /** * 保存当前编辑框实例的内容到草稿 */ function saveDraft() { /** @type {Draft} */ const draft = { content: pcontent.value, title: ptitle ? ptitle.value : '', id, }; const empty = draft.content === '' && draft.title === ''; /** @type {Draft[]} */ const drafts = GM_getValue('drafts'); const index = drafts.findIndex(d => d.id === id); if (index > -1) { // 已存在此编辑器实例的草稿,直接修改覆盖 empty ? drafts.splice(index, 1) : drafts.splice(index, 1, draft); } else { // 此编辑器实例草稿尚未保存,创建新草稿保存 empty || drafts.push(draft); // 保证总条目数不超过最大设定数量 drafts.splice(0, drafts.length - CONST.Internal.UBBEditorMaximumDraft); } GM_setValue('drafts', drafts); } } ), ]); } /** * 检查给定链接是否为符合文库书评语法格式的图片链接 * @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; } } }, bookpage: { desc: '小说信息页功能增强', checkers: [{ type: 'regpath', value: /\/book\/\d+\.htm/ }, { type: 'path', value: '/modules/article/articleinfo.php' }], dependencies: ['utils'], async func() { /** @type {utils} */ const utils = require('utils'); const pool_funcs = { metacopy: { desc: '在小说信息页提供复制小说元标签的功能', detectDom: '.main.m_foot', func() { // 书名 const b = $('#content > div:first-of-type > table:first-of-type table b'); const name = b.innerText; const button = makeCopyButton(e => { GM_setClipboard(name, 'text/plain'); Quasar.Notify.create({ type: 'success', message: CONST.Text.MetaCopy.Copied, caption: name, group: 'metacopy.copied', }); }); button.style.removeProperty('padding-left'); b.after(button); // 元标签 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(':'); const button = makeCopyButton(e => { GM_setClipboard(val, 'text/plain'); Quasar.Notify.create({ type: 'success', message: CONST.Text.MetaCopy.Copied, caption: val, group: 'metacopy.copied', }); }); td.insertAdjacentElement('beforeend', button); } /** * @param {(e: PointerEvent) => any} callback - 按钮回调 * @returns {HTMLSpanElement} */ function makeCopyButton(callback) { return $$CrE({ tagName: 'span', props: { innerText: CONST.Text.MetaCopy.CopyButton, }, styles: { color: 'var(--q-primary)', cursor: 'pointer', paddingLeft: '0.5em', }, listeners: [['click', callback]] }) } } }, tagjump: { desc: '点击标签跳转标签小说列表页', detectDom: '.main.m_foot', func() { const b = $('#content > div:first-of-type > table:nth-of-type(2) td:nth-of-type(2) > span.hottext:first-of-type > b'); Assert(b.innerText.toLowerCase().includes('tags'), 'bookpage.tagjump: Cannot find tags'); const str_tags = b.innerText.split(/[︰:]/)[1]; const tags = str_tags.split(/\s+/).filter(tag => !!tag); b.innerHTML = b.innerText.replace(str_tags, '') + tags.map(tag => `<a class="plus-tag" href="https://${ location.host }/modules/article/tags.php?t=${ $URL.encode(tag) }" target="_blank">${ tag }</a>`) .join(' '); addStyle(` .plus-tag:is(.plus-darkmode *, :not(.plus-darkmode *)) { color: var(--q-primary); } `); } }, details: { desc: '添加查看详情数据的功能', detectDom: '.main.m_foot', async func() { /** @type {sidepanel} */ const sidepanel = await require('sidepanel', true); /** @type {api} */ const api = await require('api', true); sidepanel.registerButton({ id: 'bookpage.details.details', label: CONST.Text.BookDetails.ShowDetails, icon: 'bar_chart', index: 3, async callback() { // 获取数据 const BookDetails = CONST.Text.BookDetails; const aid = parseInt( new URLSearchParams(location.search).get('id') ?? location.href.match(/book\/(\d+)\.htm/)?.[1], 10); const doc = await api.getNovelFullMeta({ aid }); const title = $(doc, 'data[name="Title"]').firstChild.nodeValue; const meta = [ 'DayHitsCount', 'TotalHitsCount', 'PushCount', 'FavCount', ].reduce((meta, key) => { const name = BookDetails.DataNames[key]; const val = parseInt($(doc, `data[name=${ escJsStr(key) }]`).getAttribute('value'), 10); meta[name] = val; return meta; }, {}); const message = Object.entries(meta).map(([name, val]) => `${name}: ${val}`).join('\n'); const html_message = `<div class="text-body1">${ message.replaceAll('\n', '<br>') }</div>`; const dialog_title = replaceText(BookDetails.Dialog.Title, { '{Name}': title, }); // Dialog输出 Quasar.Dialog.create({ title: dialog_title, message: html_message, html: true, ok: { label: BookDetails.Dialog.Ok, color: 'primary', }, cancel: { label: BookDetails.Dialog.Cancel, color: 'secondary', }, }).onCancel(() => GM_setClipboard(`${ dialog_title }\n${ message }`)); } }); } }, }; const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { GM_setValue, GM_getValue }); await promise; }, }, 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', 'mousetip'], 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'); /** @type {mousetip} */ const mousetip = require('mousetip'); 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: 4, 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, { filter: '.plus-nosort', 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'); mousetip.set($(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: ['plus-nosort', '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); }); modified && GM_setValue('folds', folds); // 在unused中有记录,但本次观察到出现的板块,清除在unused中的记录 /** @type {DisappearRecord[]} */ const unused = GM_getValue('unused'); modified = false; unused.filter(r => titles.includes(r.title)).forEach(record => { unused.splice(unused.indexOf(record), 1); modified = true; }); modified && GM_setValue('unused', unused); }); // 样式 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; background: transparent; } .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="text-lightdark bg-lightdark"> <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: 5, 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"> <div class="row justify-start"> <div v-for="(book, i) of books" class="col-xl-3 col-lg-4 col-md-6 col-sm-12 col-xs-12" style="padding: 1em"> <q-card> <q-card-section horizontal> <q-card-section class="col-2"> <a :href="book_urls[book.aid]" style="width: 100%;" target="_blank"> <q-img :src="book.cover"></q-img> </a> </q-card-section> <q-card-section class="col-5 text-body2 column justify-evenly"> <div class="text-h6 col-5"> <a :href="book_urls[book.aid]" target="_blank">{{ book.name }}</a> </div> <div class="col-3"> ${ UI.TimeAdded }{{ new Date(book.time_added).toLocaleDateString() }} </div> <div class="col-3"> ${ UI.VotedCount }{{ book.voted }} </div> </q-card-section> <q-card-actions class="col-5 row"> <div class="col-8"> <q-input v-model.number="book.votes" type="number" label="${ UI.Votes }"></q-input> </div> <div class="col-4 row items-center justify-center"> <q-btn icon="delete_outline" @click="remove(book.aid)" flat></q-btn> </div> </q-card-actions> </q-card-section> </q-card> </div> </div> </q-page> </q-page-container> <q-footer bordered class="bg-lightdark text-lightdark"> <q-toolbar> <q-toolbar-title class="text-body1 q-gutter-md row"> <span class="col q-gutter-md"> <span>${ UI.TotalVotes }{{ total_votes }}</span> <span>${ UI.TotalBooks }{{ total_books }}</span> </span> </q-toolbar-title> </q-toolbar> </q-footer> </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`}), {}); }, /** * 已分配的总票数 * @type {number} */ total_votes() { /** @type {Book[]} */ const books = this.books; return books.reduce((num, book) => num + (typeof book.votes === 'number' ? book.votes : 0), 0); }, /** * 所有参与推荐的小说数 * @type {number} */ total_books() { return this.books.length; }, }, 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: 5, 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: ['dependencies', 'utils', 'configs', 'storageupdater', 'mousetip'], params: ['GM_setValue', 'GM_getValue', 'GM_listValues', 'GM_deleteValue', 'GM_addValueChangeListener'], async func(GM_setValue, GM_getValue, GM_listValues, GM_deleteValue, GM_addValueChangeListener) { /** @type {utils} */ const utils = require('utils'); /** @type {configs} */ const configs = require('configs'); /** @type {storageupdater} */ const storageupdater = require('storageupdater'); /** @type {mousetip} */ const mousetip = require('mousetip'); /** * 记录书评的楼层高度信息,用于判断是否有新楼层 * @typedef {Object} ReviewRecord * @property {number} top - 目前已记录的最高楼层号,用于判断是否有新楼层 * @property {number} last_check - 上次检查楼层更新的时间 * @property {boolean} has_new - 是否已检查发现有新楼层 */ /** * @typedef {Object} Review * @property {number} rid * @property {string} name * @property {ReviewRecord} record - 最高楼层信息,用于判断是否有新楼层 * @property {number} last_active - 上次查看此书评时间,用于超时自动移除收藏 */ 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, /** @type {number} */ check_interval: 12, /** @type {boolean} */ add_on_reply: false, /** @type {number} */ auto_remove_timeout: -1, 'config_version': 1, }, GM_getValue); // 存储数据更新 storageupdater.update([ function v0_v1(config) { /** @type {Review[]} */ const reviews = config.reviews; reviews.forEach(review => review.record = { has_new: true, last_check: 0, top: 0, }); return config; }, function v1_v2(config) { /** @type {Review[]} */ const reviews = config.reviews; const now = Date.now(); reviews && reviews.forEach(review => review.last_active = now); return config; } ], { GM_getValue, GM_setValue, GM_listValues, GM_deleteValue }); 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); }, }, { type: 'number', label: Settings.NewFloorCheckInterval, caption: Settings.NewFloorCheckIntervalCaption, reload: true, key: 'check_interval', get() { return GM_getValue('check_interval'); }, set(val) { GM_setValue('check_interval', val); }, }, { type: 'boolean', label: Settings.AddOnReply, caption: Settings.AddOnReplyCaption, key: 'add_on_reply', get() { return GM_getValue('add_on_reply'); }, set(val) { GM_setValue('add_on_reply', val); }, }, { type: 'number', label: Settings.AutoRemoveTimeout, caption: Settings.AutoRemoveTimeoutCaption, key: 'auto_remove_timeout', get() { return GM_getValue('auto_remove_timeout'); }, set(val) { GM_setValue('auto_remove_timeout', val); }, }], label: Settings.Title, }); const pool_funcs = { /* gui: { desc: '收藏书评管理界面', async func() { const container = $CrE('div'); container.innerHTML = ` <q-dialog v-model="visible"> </q-dialog> `; }, }, */ indexlist: { 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; } .plus-badge { position: relative; } .plus-badge::before { content: ""; position: absolute; top: -5px; left: -10px; width: 10px; height: 10px; background: var(--plus-text-poptext); border-radius: 50%; } .plus-darkmode .plus-badge::before{ background: #f36d55; } `); /** * 创建书评列表展示框并添加到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, }, classes: review.record.has_new ? ['plus-badge'] : [], }); const tip = (review.record.has_new ? CONST.Text.ReviewCollection.HasNewFloors : '') + review.name; mousetip.set(a, tip); li.append(a); ul.append(li); }); // 添加到页面 $('#plus-review-collection')?.remove(); const parent = $(({ left: '#left', right: '#right', }) [GM_getValue('list_position')]); parent.append(block); } }, }, reviewbutton: { desc: '在书评页面添加收藏按钮', checkers: { type: 'path', value: '/modules/article/reviewshow.php', }, dependencies: ['checker'], async func() { /** @type {checker} */ const checker = pool.require('checker'); /** @type {sidepanel} */ const sidepanel = await require('sidepanel', true); const rid = parseInt(new URLSearchParams(location.search).get('rid'), 10); toggleSideButton(); ['enabled', 'reviews'].forEach(key => GM_addValueChangeListener(key, (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; let in_collection = GM_getValue('reviews').some(r => r.rid === rid); if (enabled) { sidepanel.hasButton(ButtonID) ? sidepanel.updateButton(ButtonID, { label: in_collection ? ReviewCollection.Remove : ReviewCollection.Add, icon: in_collection ? 'bookmark' : 'bookmark_border', }) : sidepanel.registerButton({ id: ButtonID, label: in_collection ? ReviewCollection.Remove : ReviewCollection.Add, icon: in_collection ? 'bookmark' : 'bookmark_border', index: 2, async callback() { // 添加收藏需要时间(以fetch最后一页获取最高楼层号),按钮置为工作中状态 sidepanel.updateButton(ButtonID, { loading: true, }); // 修改书评收藏 const in_collection = await toggleCurrentReview(); // 提示 Quasar.Notify.create({ type: 'success', message: in_collection ? ReviewCollection.Added : ReviewCollection.Removed, group: 'reviewcollection.toggle' }); // 更新按钮 sidepanel.updateButton(ButtonID, { loading: false, label: in_collection ? ReviewCollection.Remove : ReviewCollection.Add, icon: in_collection ? 'bookmark' : 'bookmark_border', }); } }); } else { sidepanel.hasButton(ButtonID) && sidepanel.removeButton(ButtonID); } } } }, addonreply: { desc: '回复时自动加入收藏', checkers: { type: 'path', value: '/modules/article/reviewshow.php', }, detectDom: 'form[name="frmreview"]', func() { const form = $('form[name="frmreview"]'); $AEL(form, 'submit', e => { if (!GM_getValue('add_on_reply')) { return; } // 添加收藏 toggleCurrentReview(true); }); }, }, checker: { desc: '定期检查是否有新楼层、清理未访问书评', // 检查发现有新楼层时,记录下来,根据新楼层记录在界面上提示用户;当用户打开对应帖子页面时,清除新楼层记录,刷新最高楼层记录 /** @typedef {Awaited<ReturnType<typeof pool_funcs.checker.func>>} checker */ async func() { const pool_funcs = { newfloor: { desc: '检查新楼层', async func() { /** @type {number} */ const check_interval = GM_getValue('check_interval'); const check_interval_ms = check_interval * 60 * 60 * 1000; const check_interval_inpage = Math.max(CONST.Internal.ReviewUpdateMinCheckInterval, check_interval_ms); if (check_interval < 0) { return; } // 打开页面时,自动检查一次 doCheck(); // 在页面内,每过一段时间自动检查一次 // 即使设置了极短的检查间隔,这段时间间隔不能短于一定最短长度,防止快速产生大量请求 // 如需快速即时检查是否有更新,可以打开书评最后一页,利用页面自动更新检查;或手动刷新页面 setInterval(doCheck, check_interval_inpage); async function doCheck() { /** @type {Review[]} */ const reviews = GM_getValue('reviews'); const now = Date.now(); let modified = false; for (const review of reviews) { if (now - review.record.last_check < check_interval_ms) { // 未到检查最短时间间隔 continue; } // 获取当前最高楼层号 const top = await getLastFloorNumber(review.rid); review.record.last_check = now; modified = true; // 和存储的最高楼层号比对,检查是否有新楼层 if (top > review.record.top) { // 记录:此帖有新楼层 review.record.has_new = true; // 记录:新的最高楼层号 review.record.top = top; } } modified && GM_setValue('reviews', reviews); } } }, record: { desc: '书评页清除新楼层记录', checkers: { type: 'path', value: '/modules/article/reviewshow.php', }, async func() { /** @type {Review[]} */ const reviews = GM_getValue('reviews'); const rid = parseInt(new URLSearchParams(location.search).get('rid'), 10); const review = reviews.find(review => review.rid === rid); if (review) { await doRecord(); require('review', true).then( /** @param {review} review */ review => { $AEL(review.messager, 'update', e => doRecord()); } ); } async function doRecord() { // 若当前页面最大楼层号大于等于本书评记录的最高楼层号,则可清除新楼层记录 /** @type {Review[]} */ const reviews = GM_getValue('reviews'); const review = reviews.find(review => review.rid === rid); if (!review) { return; } const page_top = await getLastFloorNumber(review.rid, document); if (page_top >= review.record.top) { if (!document.hidden) { // 标签页可见时,清除新楼层记录 review.record.has_new = false; } else if (page_top > review.record.top) { // 标签页不可见,且楼层有更新时,记下新楼层记录 review.record.has_new = true; } else { // 其余情况:标签页不可见且无新楼层,无数据更新,仅更新last_check即可 // 这个else分支什么都不用做 } // 刷新最高楼层记录 review.record.top = page_top; review.record.last_check = Date.now(); // 保存 GM_setValue('reviews', reviews); } } } }, removeinactive: { desc: '清除长时间未访问的书评收藏', func() { /** @type {Review[]} */ const reviews = GM_getValue('reviews'); const now = Date.now(); const timeout = GM_getValue('auto_remove_timeout'); if (timeout < 0) { return; } /** @type {Review[]} */ const inactive_reviews = []; reviews.forEach(review => { const inactive = now - review.last_active > timeout; inactive && inactive_reviews.push(review); }); const active_reviews = reviews.filter(r => inactive_reviews.every(rw => rw.rid !== r.rid)); GM_setValue('reviews', active_reviews); } }, activate: { desc: '书评页记录书评访问', checkers: { type: 'path', value: '/modules/article/reviewshow.php', }, func() { /** @type {Review[]} */ const reviews = GM_getValue('reviews'); const rid = parseInt(new URLSearchParams(location.search).get('rid'), 10); const review = reviews.find(review => review.rid === rid); if (!review) { return; } review.last_active = Date.now(); GM_setValue('reviews', reviews); } } }; const { promise, pool } = utils.loadFuncInNewPool(pool_funcs); await promise; /** * 获取给定书评最高楼层号 * @param {number} rid * @param {Document} [doc] - 如果提供此参数,则直接从中获取最高楼层;否则发起网络请求该书评最后一页,再获取最高楼层 * @returns */ async function getLastFloorNumber(rid, doc=null) { doc = doc ?? await utils.requestDocument({ method: 'GET', url: `https://${location.host}/modules/article/reviewshow.php?rid=${rid}&page=last`, }); /** @type {HTMLAnchorElement[]} */ const links = $All(doc, '#content > table > tbody > tr > td:last-of-type > div:nth-of-type(2) > a[href^="#yid"]'); const last = links[links.length-1]; const number = parseInt(last.innerText.match(/\d+/)[0], 10); return number; } return { getLastFloorNumber, }; } }, }; const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { GM_setValue, GM_getValue, GM_addValueChangeListener }); await promise; /** * 在书评页面执行,为当前页面的书评切换收藏/未收藏状态 * @param {boolean} [target=null] - 是希望添加收藏(true)还是移除收藏(false),如果发现已在收藏列表/不在收藏列表就什么也不做;省略此参数时,自动切换收藏状态,即已在收藏列表时移除收藏、不在收藏列表时添加收藏 * @returns {Promise<boolean>} 切换后是否为已收藏状态 */ async function toggleCurrentReview(target = null) { /** @type {checker} */ const checker = pool.require('checker'); /** @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]); /** @type {ReviewRecord} */ const record = { top: await checker.getLastFloorNumber(rid), last_check: Date.now(), has_new: false, }; const in_collection = reviews.some(r => r.rid === rid); if (target !== false && !in_collection) { // 需要添加书评收藏 const last_active = Date.now(); reviews.push({ rid, name, record, last_active }); } else if (target !== true && in_collection) { // 需要移除书评收藏 const index = reviews.findIndex(r => r.rid === rid); reviews.splice(index, 1); } GM_setValue('reviews', reviews); return !in_collection; } }, }, background: { desc: '自定义页面背景', detectDom: 'body', dependencies: ['utils', 'configs', 'storageupdater'], params: ['GM_setValue', 'GM_getValue', 'GM_listValues', 'GM_deleteValue', 'GM_addValueChangeListener'], func(GM_setValue, GM_getValue, GM_listValues, GM_deleteValue, GM_addValueChangeListener) { /** @type {utils} */ const utils = require('utils'); /** @type {configs} */ const configs = require('configs'); /** @type {storageupdater} */ const storageupdater = require('storageupdater'); /** * @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 {boolean} */ mask_blur: false, /** @type {string} */ color: 'rgb(255, 255, 255)', 'config_version': 1, }, GM_getValue); // 存储数据更新 storageupdater.update([ function v0_v1(config) { // 去除透明度部分 const reg = /rgba\((\d+, *\d+, *\d+), *\d+(\.\d*)?\)/; const match = config.color.match(reg); if (match) { config.color = `rgb(${ match[1] })`; } return config; }, ], { GM_setValue, GM_getValue, GM_listValues, GM_deleteValue }); // 创建背景 /** * 背景管理器 * @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, mask_blur) { // 背景图片 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, }, }); // 创建一个position: fixed的div,防止内容撑高页面滚动高度 const fixed_div = $$CrE({ tagName: 'div', attrs:{ id: 'plus-background-mask-positioner', }, styles: { position: 'fixed', left: '0', top: '0', width: '100vw', height: '100vh', zIndex: '-1', overflow: 'auto', }, }); // fixed_div内部创建和网页标准文档流等高的矩形元素,使fixed_div内部滚动条和标准文档流一致 const div_content = $$CrE({ tagName: 'div', classes: 'plus-main', styles: { width: '960px', height: `${ document.body.scrollHeight }px`, }, }); fixed_div.append(div_content); document.body.append(fixed_div); // fixed_div内部再创建遮罩层,通过和文库.main相同方式定位到横向中心 // mask_container放在等高矩形下面,纵向位置上相当于标准文档流的末尾 const mask_container = $$CrE({ tagName: 'div', classes: ['plus-main'], attrs: { id: 'plus-background-mask-container' }, styles: { position: 'relative', height: '0' } }); // 遮罩层根据mask_container定位,横向定位在考虑过滚动条的中心,纵向从-5000vh开始,高度10000vh,覆盖全屏幕高度 const mask = $$CrE({ tagName: 'div', attrs:{ id: 'plus-background-mask', }, styles: { position: 'absolute', bottom: '-500000vh', left: '0', width: '960px', height: '1000000vh', zIndex: '-1', backdropFilter: mask_blur ? 'blur(10px)' : 'none', } }); mask_container.append(mask); fixed_div.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: var(--plus-background-mask-light); } .plus-darkmode #plus-background-mask { background: var(--plus-background-mask-dark); } `, 'plus-background-style'); addStyle(` :root { --plus-background-mask-light: rgba(255, 255, 255, ${mask_opacity}); --plus-background-mask-dark: rgba(0, 0, 0, ${mask_opacity}); } `, 'plus-background-style-adjust'); }, update(url, mask_opacity, image_fit, mask_blur) { $('#plus-background-img').src = url; $('#plus-background-img').style.objectFit = image_fit; $('#plus-background-mask').style.backdropFilter = mask_blur ? 'blur(10px)' : 'none'; addStyle(` :root { --plus-background-mask-light: rgba(255, 255, 255, ${mask_opacity}); --plus-background-mask-dark: rgba(0, 0, 0, ${mask_opacity}); } `, 'plus-background-style-adjust'); }, uninstall() { $('#plus-background-img')?.remove(); $('#plus-background-mask-positioner')?.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: 'boolean', label: Settings.MaskBlur, key: 'mask_blur', get() { return GM_getValue('mask_blur'); }, set(val) { GM_setValue('mask_blur', 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'), GM_getValue('mask_blur'), ]; 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: true, 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: false, dependencies: ['dependencies', 'utils', 'configs', 'mousetip'], 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'); /** @type {mousetip} */ const mousetip = require('mousetip'); /** * @typedef {Object} BlockUserInfo * @property {string} username * @property {string} avatar * @property {number} time_added */ /** * @typedef {Object} BlockBookInfo * @property {string} name * @property {string} cover * @property {number} time_added */ /** @typedef {BlockUserInfo | BlockBookInfo} BlockInfo */ /** * @typedef {Object} BlockTarget * @property {'user' | 'book'} type * @property {number} id * @property {BlockInfo} info */ GM_getValue = utils.defaultedGet({ /** @type {BlockTarget[]} */ blocklist: [], /** @type {boolean} */ enabled: true, }, GM_getValue); const Settings = CONST.Text.Blocking.Settings; configs.registerConfig('blocking', { label: Settings.Label, items: [{ type: 'boolean', label: Settings.Enabled, caption: Settings.EnabledCaption, reload: true, key: 'enabled', get() { return GM_getValue('enabled'); }, set(val) { return GM_setValue('enabled', val); }, }, { type: 'button', label: Settings.BlockList, button_icon: 'edit_note', button_label: Settings.BlockListEdit, callback() { gui.show(); }, }], GM_addValueChangeListener }) 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() { if (!GM_getValue('enabled')) { return } 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() { if (!GM_getValue('enabled')) { return } 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() { if (!GM_getValue('enabled')) { return } /** @type {userpage} */ const userpage = await require('userpage', true); const page = userpage.PageManager.page; const uid = parseInt(new URLSearchParams(location.search).get('uid'), 10); const username = (await detectDom('#left > div.block:first-of-type .ulrow > li > strong')).innerText; const avatar = (await detectDom('#left > div.block:first-of-type .ulrow > li > img')).src; 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() { const time_added = Date.now(); 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, { username, avatar, time_added }), }); } /** * 根据目前屏蔽状态,添加/移除屏蔽提示 */ 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; } }, bookblock: { desc: '屏蔽书籍', async func() { const pool_funcs = { blocktoggle: { desc: '书籍信息页屏蔽/解除屏蔽功能', checkers: [{ type: 'regpath', value: /\/book\/\d+\.htm/ }, { type: 'path', value: '/modules/article/articleinfo.php' }], async func() { /** @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 = (await detectDom('#content > div:first-of-type > table table span > b')).innerText; const cover = (await detectDom('#content > div:first-of-type > table:last-of-type img')).src; // 屏蔽按钮 GM_getValue('enabled') && sidepanel.registerButton({ id: 'bookblock.block', label: 'Button Label to be updated', icon: 'icon to be updated', // hourglass_top // 沙漏图标也许可用来占位 index: 6, callback() { let blocked = bookBlocked(aid); blocked ? unBlockBook(aid) : blockBook(aid, { name, cover, time_added: Date.now() }); blocked = !blocked; updateSideButton(); const notify_message = replaceText( blocked ? CONST.Text.Blocking.BlockedBook : CONST.Text.Blocking.UnBlockedBook, { '{Name}': name }); Quasar.Notify.create({ type: 'success', message: notify_message, group: 'blocking.book.toggle' }); } }); updateSideButton(); GM_addValueChangeListener('blocklist', (key, old_val, new_val, remote) => updateSideButton()); // 本书被屏蔽文字提示 await blockTip(); GM_addValueChangeListener('blocklist', async (key, old_val, new_val, remote) => await blockTip()); /** 根据目前书籍屏蔽状态更新按钮外观 */ function updateSideButton() { const isBlocked = bookBlocked(aid); sidepanel.updateButton('bookblock.block', { label: isBlocked ? CONST.Text.Blocking.UnBlockBook : CONST.Text.Blocking.BlockBook, icon: isBlocked ? 'do_not_disturb_off' : 'block', }); } /** 本书被屏蔽文字提示 */ async function blockTip() { if (bookBlocked(aid)) { const span = utils.html2elm(`<span class="text-primary" id="plus-blocktip" style="font-size:13px;"><br><b>${ CONST.Text.Blocking.BookBlocked }</b></span>`); const parent = await detectDom('#content > div:first-of-type > table:nth-of-type(2) td:first-of-type'); $('#plus-blocktip')?.remove(); parent.append(span); } else { $('#plus-blocktip')?.remove(); } } } }, blockutils: { desc: '小说屏蔽专用的工具函数集', /** @typedef {Awaited<ReturnType<typeof pool_funcs.blockutils.func>>} blockutils */ func() { addStyle(` .plus-block-mask { position: absolute; left: 0; right: 0; top: 0; bottom: 0; display: flex; justify-content: center; align-items: center; background: rgba(255,255,255,0.2); backdrop-filter: blur(30px); } .plus-darkmode .plus-block-mask { background: rgba(0,0,0,0.2); } .plus-block-mask > i { font-family: 'Material Icons'; font-size: 30px; font-style: normal; user-select: none; } .plus-block-mask.plus-block-tempshow { display: none; } `, 'plus-blocking-book'); /** * 在小说上展示表示屏蔽的遮罩 * @param {HTMLDivElement} div - 需要被遮罩挡住的元素 * @param {Object} options - 细节设定 * @param {string} options.icon_size - 图标大小,默认30px * @param {string} options.icon_name - 图标名称,默认为visibility_off */ function showBlock(div, { icon_size, icon_name } = {}) { // 去除掉元素内已有的遮罩,防止重复创建 hideBlock(div); // 设置元素position方便遮罩定位 div.style.position = 'relative'; // 创建标准遮罩 const mask = $$CrE({ tagName: 'div', classes: 'plus-block-mask', listeners: [['dblclick', e => { mask.classList.add('plus-block-tempshow'); setTimeout(() => mask.classList.remove('plus-block-tempshow'), CONST.Internal.BlockingBookTempShowTime); }]], }); mousetip.set(mask, CONST.Text.Blocking.BookBlockedTip); const icon = $$CrE({ tagName: 'i', props: { innerText: 'visibility_off', }, }); // 根据options自定义遮罩样式 icon_size && icon.style.setProperty('font-size', icon_size); icon_name && (icon.innerText = icon_name); // 添加遮罩到DOM中 mask.append(icon); div.append(mask); } /** * 删除小说上表示屏蔽的遮罩 * @param {HTMLDivElement} div */ function hideBlock(div) { div.style.removeProperty('position'); $(div, '.plus-block-mask')?.remove(); } return { showBlock, hideBlock }; }, }, bookindex: { desc: '书籍信息页', checkers: [{ type: 'regpath', value: /\/book\/\d+\.htm/ }, { type: 'path', value: '/modules/article/articleinfo.php' }], dependencies: ['blockutils'], async func() { if (!GM_getValue('enabled')) { return; } /** @type {blockutils} */ const blockutils = pool.require('blockutils'); doBlock(); GM_addValueChangeListener('blocklist', async (key, old_val, new_val, remote) => doBlock()); function doBlock() { // 同分类小说推荐 和 同分类完本推荐 Array.from($All('#content > div:last-of-type > table > tbody > tr > td:nth-of-type(2n) > div > div')).forEach( /** @param {HTMLDivElement} div */ div => { /** @type {HTMLAnchorElement} */ const a = div.firstElementChild; const aid = parseInt(a.getAttribute('href').match(/(\d+)\.htm/)[1], 10); //bookBlocked(aid) ? div.style.setProperty('display', 'none') : div.style.removeProperty('display'); bookBlocked(aid) ? blockutils.showBlock(div) : blockutils.hideBlock(div); } ); } } }, index: { desc: '主页', checkers: [{ type: 'path', value: '/' }, { type: 'path', value: '/index.php' }], dependencies: ['blockutils'], async func() { if (!GM_getValue('enabled')) { return; } /** @type {blockutils} */ const blockutils = pool.require('blockutils'); doBlock(); GM_addValueChangeListener('blocklist', async (key, old_val, new_val, remote) => doBlock()); function doBlock() { Array.from($All('.blockcontent > div > div > a:first-of-type')).forEach( /** @param {HTMLAnchorElement} */ a => { const div = a.parentElement; const aid = parseInt(a.getAttribute('href').match(/(\d+)\.htm/)[1], 10); //bookBlocked(aid) ? div.style.setProperty('display', 'none') : div.style.removeProperty('display'); bookBlocked(aid) ? blockutils.showBlock(div) : blockutils.hideBlock(div); } ) } } }, booklist: { desc: '书籍列表页', checkers: [{ type: 'path', value: '/modules/article/articlelist.php' }, { type: 'path', value: '/modules/article/toplist.php' }], dependencies: ['blockutils'], async func() { if (!GM_getValue('enabled')) { return; } /** @type {blockutils} */ const blockutils = pool.require('blockutils'); doBlock(); GM_addValueChangeListener('blocklist', async (key, old_val, new_val, remote) => doBlock()); function doBlock() { Array.from($All('#content > table:last-of-type td > div > div > a')).forEach( /** @param {HTMLAnchorElement} a */ a => { const div = a.parentElement.parentElement; const aid = parseInt(a.getAttribute('href').match(/(\d+)\.htm/)[1], 10); bookBlocked(aid) ? blockutils.showBlock(div) : blockutils.hideBlock(div); } ); } } }, top: { desc: '热度排名页', checkers: { type: 'path', value: '/top.php', }, dependencies: ['blockutils'], async func() { if (!GM_getValue('enabled')) { return; } /** @type {blockutils} */ const blockutils = pool.require('blockutils'); doBlock(); GM_addValueChangeListener('blocklist', async (key, old_val, new_val, remote) => doBlock()); function doBlock() { Array.from($All('.ultop > li > a')).forEach( /** @param {HTMLAnchorElement} a */ a => { const li = a.parentElement; const aid = parseInt(a.getAttribute('href').match(/(\d+)\.htm/)[1], 10); bookBlocked(aid) ? blockutils.showBlock(li, { icon_size: '1.5em', }) : blockutils.hideBlock(li); } ); } } }, example: { desc: '示例页', disabled: true, async func() { if (!GM_getValue('enabled')) { return; } /** @type {blockutils} */ const blockutils = pool.require('blockutils'); doBlock(); GM_addValueChangeListener('blocklist', async (key, old_val, new_val, remote) => doBlock()); function doBlock() { // } } }, }; const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { GM_setValue, GM_getValue, GM_addValueChangeListener }); await promise; } }, gui: { desc: '管理屏蔽列表的GUI', /** @typedef {Awaited<ReturnType<typeof pool_funcs.gui.func>>} gui */ async func() { const container = $CrE('div'); const UI = CONST.Text.Blocking.UI; container.innerHTML = ` <q-dialog v-model="visible" full-width full-height class="plus-blocking-gui"> <q-layout container view="hHh lpR fFf"> <q-header bordered class="bg-primary text-white"> <q-toolbar> <q-toolbar-title> <q-avatar icon="list_alt"></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="(item, i) of blocklist"> <q-item-section avatar> <a :href="item_info[i].url" style="width: 100%;" target="_blank"> <q-img :src="item_info[i].image"></q-img> </a> </q-item-section> <q-item-section> <q-item-label class="text-body1"> <a :href="item_info[i].url" target="_blank">{{ item_info[i].text }}</a> </q-item-label> <q-item-label caption> ${ UI.TimeAdded }{{ item_info[i].time }} </q-item-label> </q-item-section> <q-item-section avatar> <q-btn icon="delete_outline" @click="remove(i)" 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, blocklist: GM_getValue('blocklist'), }; }, computed: { /** @typedef {{ image: string, text: string, url: string, time: string }} ItemGUI */ /** * 根据屏蔽条目自动生成GUI显示信息 * @type {ItemGUI[]} */ item_info() { return this.blocklist.map( /** * @param {BlockTarget} item * @returns {ItemGUI} */ item => { switch (item.type) { case 'user': return { image: item.info.avatar, text: item.info.username, url: `https://${location.host}/userpage.php?uid=${item.id}`, time: new Date(item.info.time_added).toLocaleString(), }; case 'book': return { image: item.info.cover, text: item.info.name, url: `https://${location.host}/book/${item.id}.htm`, time: new Date(item.info.time_added).toLocaleString(), }; } }); }, }, methods: { /** * 删除一个屏蔽项 * @param {number} i - 该项当前在items中的下标 */ remove(i) { const item = this.blocklist[i]; const info = this.item_info[i]; Quasar.Dialog.create({ title: UI.ConfirmRemove.Title, message: replaceText( UI.ConfirmRemove.Message, { '{Name}': info.text } ), ok: { label: UI.ConfirmRemove.Ok, color: 'primary', }, cancel: { label: UI.ConfirmRemove.Cancel, color: 'secondary', }, }).onOk(() => this.blocklist.splice(i, 1)) } }, watch: { // 自动保存配置更改到存储空间 blocklist: { handler(new_val, old_val) { GM_setValue('blocklist', new_val); }, deep: true }, }, mounted() { const that = this; instance = this; // 自动根据存储的推书配置更新UI GM_addValueChangeListener('blocklist', (key, old_val, new_val, remote) => { // 防抖,只有真正发生改变时才更新数据到UI if (!utils.deepEqual(old_val, new_val, true)) { that.blocklist = new_val; } }) } }); app.use(Quasar); app.mount(container); function show() { instance.visible = true; } function hide() { instance.visible = false; } return { show, hide, } } } }; const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { GM_setValue, GM_getValue, GM_addValueChangeListener }); await promise; /** @type {gui} */ const gui = await pool.require('gui', true); /** * 屏蔽指定用户 * @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 * @param {BlockInfo} [info={}] */ function blockUser(uid, info={}) { block(uid, 'user', info); } /** * 屏蔽指定书籍 * @param {number} aid * @param {BlockInfo} [info={}] */ function blockBook(aid, info={}) { block(aid, 'book', info); } /** * 屏蔽给定对象 * @param {number} id * @param {'user' | 'book'} type * @param {BlockInfo} [info={}] */ function block(id, type, info={}) { if (({ 'user': userBlocked, 'book': bookBlocked, })[type](id)) { return; } /** @type {BlockTarget[]} */ const blocklist = GM_getValue('blocklist'); blocklist.push({ id, type, info }); 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); } } }, reader: { desc: '在线阅读优化', dependencies: ['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 = utils.defaultedGet({ /** @type {boolean} */ enabled: true, /** @type {string[]} 已保存的字体列表,包含内置的字体名称和用户自己填写的字体名称 */ saved_fonts: CONST.Text.Reader.UI.FontOptions, /** @type {string} 用户当前应用的字体 */ font: '宋体, 新细明体, Verdana, Arial, sans-serif', /** @type {number} 用户当前应用的字号 */ font_size: 16, /** @type {string} */ color: 'black', }, GM_getValue); const pool_funcs = { gui: { desc: '字体调节界面', /** @typedef {Awaited<ReturnType<typeof pool_funcs.gui.func>>} gui */ async func() { const container = $CrE('div'); const UI = CONST.Text.Reader.UI; container.innerHTML = ` <q-dialog v-model="visible" position="top"> <q-card class="q-pa-md"> <q-list> <q-item tag="label"> <q-item-section> <q-item-label>${ UI.Enabled }</q-item-label> <q-item-label caption>${ UI.EnabledCaption }</q-item-label> </q-item-section> <q-item-section avatar> <q-toggle v-model="config.enabled"></q-toggle> </q-item-section> </q-item> <q-item tag="label"> <q-item-section> <q-item-label>${ UI.FontFamily }</q-item-label> <q-item-label caption>${ UI.FontFamilyCaption }</q-item-label> </q-item-section> <q-item-section avatar> <p-addable-select filled v-model:options="font_options" v-model="config.font" :option-handler="option => formatOption(option)" @update:options="options => saveFontList(options)" ></p-addable-select> </q-item-section> </q-item> <q-item tag="label"> <q-item-section>${ UI.FontSize }</q-item-section> <q-item-section avatar> <q-input type="number" v-model.number="config.font_size" suffix="${ UI.FontSizeSuffix }" ></q-input> </q-item-section> </q-item> <q-item tag="label"> <q-item-section> <q-item-label>${ UI.Color }</q-item-label> <q-item-label caption>${ UI.ColorCaption }</q-item-label> </q-item-section> <q-item-section avatar> <p-color v-model="config.color" ></p-color> </q-item-section> </q-item> </q-list> </q-card> </q-dialog> `; document.body.append(container); let instance; const app = Vue.createApp({ data() { return { /** @type {boolean} */ visible: false, /** * 存储样式信息的配置对象,和GM存储保持一致 * @type {Object} */ config: ['enabled', 'font', 'font_size', 'color'].reduce((config, key) => Object.assign(config, { [key]: GM_getValue(key) }), {}), font_options: GM_getValue('saved_fonts'), }; }, methods: { /** * 格式化q-select选项为对象类型 * @param {string | { label: string, value: string }} option * @returns {{ label: string, value: string }} */ formatOption(option) { if (typeof option === 'string') { option = { label: option, value: option }; } return option; }, /** * 保存字体列表到存储空间 * @param {{ label: string, value: string }[]} fonts */ saveFontList(fonts) { // 当字体列表被用户清空时,恢复到初始状态(内置字体列表) fonts.length || (fonts = [...UI.FontOptions]); this.font_options = fonts; utils.deepEqual(GM_getValue('saved_fonts'), fonts) || GM_setValue('saved_fonts', fonts); } }, watch: { // 自动保存配置更改到存储空间 config: { handler(new_val, old_val) { Object.entries(new_val).forEach(([key, value]) => { // 遍历全部配置属性,只有真正发生更新的才执行保存到本地 if (utils.deepEqual(GM_getValue(key), value)) { return; } GM_setValue(key, value); }); }, deep: true }, }, mounted() { instance = this; // 自动根据存储的配置更新UI const update = (key, old_val, new_val, remote) => { // 防抖,只有在新旧值不等时才更新 utils.deepEqual(old_val, new_val) || (this.config[key] = new_val); }; ['font', 'font_size'].forEach(key => GM_addValueChangeListener(key, update)); } }); // 可添加项目的下拉选择框组件 /** * @typedef {Object} PAddableSelect * @property {(string | { label: string, value: string })[]} initial-options 初始选项列表 * @property {(val) => string | { label: string, value: string }} [option-handler] 处理新加入的选项的函数,当用户新添加选项时会调用,接受新选项为参数,返回值将被用作实际添加到组件的新选项;未提供时,不对选项进行处理,保持原样(通常是一个字符串)添加到组件中 * @property {(string | { label: string, value: string })[]} v-model:options 双向绑定的选项列表 * @property {string} v-model:modelValue 双向绑定的当前用户选中的值 */ app.component('p-addable-select', { name: 'PAddableSelect', props: ['modelValue', 'options', 'option-handler'], emits: ['update:modelValue', 'update:options'], template: ` <q-select :options="display_options" v-model="value" use-input input-debounce="0" @filter="filterFn" @new-value="createValue" emit-value map-options > <template v-slot:option="{ itemProps, opt, index }"> <q-item v-bind="itemProps"> <q-item-section> <q-item-label v-html="opt.label"></q-item-label> </q-item-section> <q-item-section side> <q-btn flat dense icon="close" @click="e => removeValue(e, index)" square></q-btn> </q-item-section> </q-item> </template> </q-select> `, data() { return { display_options: [...this.options], }; }, methods: { createValue(val, done) { if (val.length > 0) { if (this.options.every(opt => this.getOptionValue(opt) !== this.getOptionValue(val))) { typeof this.optionHandler === 'function' && (val = this.optionHandler(val)); this.options.push(val); } done(val, 'add-unique'); } }, removeValue(e, index) { // 停止事件冒泡,阻止quasar试图切换到这个将要移除的选项 e.stopPropagation(); // 从完整选项列表删除选项 const dropped_opt = this.options.splice(index, 1)[0]; // 从显示的选项列表删除选项 [...this.display_options].forEach(opt => { if (utils.deepEqual(opt, dropped_opt)) { const i = this.display_options.indexOf(opt); this.display_options.splice(i, 1); } }); // 如果当前选中的是被移除的选项,就改为选中选项列表第一项 if (this.value === this.getOptionValue(dropped_opt)) { // 当显示的选项列表不为空时,优先从显示的选项列表中取;否则从全部选项列表中取 // 当均为空时,回退到空值 const option = this.display_options.length ? this.display_options[0] : this.options[0] ?? null; this.value = option !== null ? this.getOptionValue(option) : ''; } }, filterFn(val, update = null) { const that = this; update = typeof update === 'function' ? update : setTimeout; update(() => { if (val === '') { that.display_options = [...that.options]; } else { const needle = val.toLowerCase(); that.display_options = that.options.filter(v => v.value.toLowerCase().includes(needle)); } }); }, /** * 获取选项的值 * @param {string | { label: string, value: string }} opt * @returns */ getOptionValue(opt) { return typeof opt === 'string' ? opt : opt.value; } }, watch: { options: { handler(new_val, old_val) { this.$emit('update:options', new_val); //this.filterFn(this.value); }, deep: true, } }, computed: { value: { get() { return this.modelValue; }, set(val) { this.$emit('update:modelValue', val); } }, }, }); // 颜色选择器 app.component('p-color', { name: 'PColor', props: ['modelValue'], emits: ['update:modelValue'], data() { return { picker_visible: false, } }, template: ` <q-input v-model="color" :rules="['anyColor']" @focus="() => picker_visible = true" > <template v-slot:prepend> <div :style="{ width: '1em', height: '1em', background: color, borderRadius: '30%', cursor: 'pointer' }" @click="() => picker_visible = true" ></div> </template> <template v-slot:append> <q-icon name="colorize" class="cursor-pointer"> <q-popup-proxy cover transition-show="scale" transition-hide="scale" v-model="picker_visible"> <q-color v-model="color" default-view="palette"></q-color> </q-popup-proxy> </q-icon> </template> </q-input> `, computed: { color: { get() { return this.modelValue; }, set(color) { this.$emit('update:modelValue', color); }, }, }, }); app.use(Quasar); app.mount(container); function show() { instance.visible = true; } function hide() { instance.visible = false; } if ( FunctionLoader.testCheckers([{ type: 'func', value() { const path = location.pathname; return path.startsWith('/novel/') && !path.endsWith('index.htm'); } }, { type: 'path', value: '/modules/article/reader.php' }]) ) { require('sidepanel', true).then( /** @param {sidepanel} sidepanel */ sidepanel => { sidepanel.registerButton({ id: 'autovote.show', icon: 'format_size', label: CONST.Text.Reader.SideButton, index: 2, callback: show, }); } ); } return { show, hide, }; }, }, reader: { desc: '阅读页面修改字体', detectDom: ['#contentmain', '#content'], checkers: [{ type: 'func', value() { const path = location.pathname; return path.startsWith('/novel/') && !path.endsWith('index.htm'); } }, { type: 'path', value: '/modules/article/reader.php' }], async func() { /** @type {HTMLDivElement} */ const style_keys = ['enabled', 'font', 'font_size', 'color']; const update = (key, old_val, new_val, remote) => utils.deepEqual(old_val, new_val) || applyStyle(); style_keys.forEach(key => GM_addValueChangeListener(key, update)); applyStyle(); function applyStyle() { const [enabled, font_family, font_size, color] = style_keys.map(key => GM_getValue(key)); enabled ? addStyle(` #contentmain { font-family: ${ font_family }; } #content { font-size: ${ font_size }px !important; } #contentmain, #content { color: ${ color } !important; } `, 'plus-reader-style') : $('#plus-reader-style')?.remove(); } } }, }; const { promise, pool } = utils.loadFuncInNewPool(pool_funcs, { GM_setValue, GM_getValue, GM_addValueChangeListener }); await promise; }, }, mousetip: { desc: '文库鼠标跟随小文字提示增强', dependencies: ['utils'], /** @typedef {Awaited<ReturnType<typeof functions.mousetip.func>>} mousetip */ async func() { /** @type {utils} */ const utils = require('utils'); const win = utils.window; // 触屏支持:触摸有tip的元素展示tip,触摸其他位置隐藏tip // 注意:诸如在线阅读这样的页面是没有加载文字提示功能的,也就是没有tipshow、tiphide等全局函数 $AEL(document, 'touchstart', e => win?.tiphide()); detectDom({ selector: '[tiptitle]', attributes: true, callback: elm => { $AEL(elm, 'touchstart', e => { e.stopPropagation(); e.pageX = e.touches[0].pageX; e.pageY = e.touches[0].pageY; win.tipmove(e); win.tipshow(elm.getAttribute('tiptitle')); }); } }); /** * @param {HTMLElement} elm * @param {string} content */ function set(elm, content) { const already_set = elm.hasAttribute('tiptitle'); elm.setAttribute('tiptitle', content); if (!already_set) { elm.setAttribute('tiptitle', content); $AEL(elm, 'mouseover', e => win.tipshow(elm.getAttribute('tiptitle'))); $AEL(elm, 'mouseout', e => win.tiphide()); } } return { set }; } }, topbar: { desc: '顶部工具栏解析与管理', dependencies: ['utils'], checkers: [{ type: 'func', value() { return !location.pathname.startsWith('/novel/'); } }], detectDom: '.main.m_top', /** @typedef {Awaited<ReturnType<typeof functions.topbar.func>>} topbar */ async func() { /** @type {utils} */ const utils = require('utils'); /** * @typedef {Object} TopBar * @property {BarLeft} left * @property {BarRight} right */ /** * @typedef {Object} BarLeft * @property {BarButton[]} buttons */ /** * @typedef {Object} BarRight * @property {BarButton[]} buttons */ /** @typedef {BarAnchorButton | BarSpanButton} BarButton */ /** * @typedef {BarButtonBase & { * element: HTMLAnchorElement, * url: string, * }} BarAnchorButton */ /** * @typedef {BarButtonBase & { * element: HTMLSpanElement, * callback: function, * }} BarSpanButton */ /** * @typedef {Object} BarButtonBase * @property {'anchor' | 'span'} type * @property {boolean} wenku * @property {number} index - 排列顺序,升序排列;左侧按钮为从左到右数,右侧按钮为从右到左数;文库自带按钮为负数,脚本新增按钮为正数 * @property {Text} prefix - 按钮元素**左侧**的文本节点,如没有则为null * @property {Text} suffix - 按钮元素**右侧**的文本节点,如没有则为null */ const pool_funcs = { parser: { desc: '解析器', /** @typedef {Awaited<ReturnType<typeof pool_funcs.parser.func>>} parser */ func() { /** * 将页面顶部工具栏解析为标准对象 * 只能解析未经修改过的文库原始顶部工具栏 * @param {HTMLDivElement} [bar] - 工具栏(.main.m_top)元素,如不提供则从网页文档中取 * @returns {TopBar} */ function parse(bar = null) { /** @type {HTMLDivElement} */ bar = bar ?? $('.main.m_top'); return { left: parseLeft(), right: parseRight(), }; /** * @returns {BarLeft} */ function parseLeft() { /** @type {BarAnchorButton[]} */ const buttons = Array.from($All(bar, '.fl a')).map((a, i, arr) => ({ type: 'anchor', wenku: true, element: a, url: a.href, index: i - arr.length, prefix: a.previousSibling, suffix: a.nextSibling, })); return { buttons }; } /** * @returns {BarRight} */ function parseRight() { /** @type {BarAnchorButton[]} */ const buttons = Array.from($All(bar, '.fr a')).reverse().map((a, i, arr) => ({ type: 'anchor', wenku: true, element: a, url: a.href, index: i - arr.length, prefix: null, suffix: a.nextSibling, })); return { buttons }; } } return { parse }; } }, transformer: { desc: '更改顶栏功能', /** @typedef {Awaited<ReturnType<typeof pool_funcs.transformer.func>>} transformer */ func() { /** * 添加一个按钮到顶栏 * @param {TopBar} bar - 标准化顶栏对象 * @param {Object} detail * @param {'left' | 'right'} detail.position - 按钮位置 * @param {'span' | 'anchor'} detail.type - 按钮类型 * @param {string} detail.text - 按钮文字 * @param {number} detail.index - 按钮排列位置,默认为添加到末尾 * @param {function} detail.callback - 按钮点击回调,仅按钮类型为'span'时有效 * @param {function} detail.url - 按钮点击跳转url,仅按钮类型为'anchor'时有效 */ function addButton(bar, { position = 'left', type = 'span', text = '', index = null, callback = null, url = '#' }) { const buttons = { left: bar.left, right: bar.right }[position].buttons; // 创建按钮 const element = $$CrE({ tagName: { span: 'span', anchor: 'a' }[type], props: { innerText: text, }, styles: { color: 'var(--q-primary)', cursor: 'pointer', }, attrs: type === 'anchor' ? { href: url } : {}, listeners: type === 'span' && callback ? [['click', e => callback()]] : [], }); /** @type {BarButton} */ const button = { type, element, wenku: false, index: index === null ? Math.max(buttons.map(b => b.index)) + 1 : index, prefix: new Text({ left: ' [', right: '' }[position]), suffix: new Text({ left: ']', right: ' ' }[position]), }; // 添加按钮并重新按照index排序按钮 buttons.push(button); buttons.sort((btn1, btn2) => btn1.index - btn2.index); // 按照排好的顺序添加元素到DOM const parent = $(`.main.m_top > ${ { left: '.fl', right: '.fr' }[position] }`); // 左侧正序添加,右侧逆序添加 ({ left: buttons, right: buttons.toReversed() })[position].forEach(btn => { btn.prefix && parent.append(btn.prefix); parent.append(btn.element); btn.suffix && parent.append(btn.suffix); }); } return { addButton }; } }, }; const { promise, pool } = utils.loadFuncInNewPool(pool_funcs); await promise; /** @type {parser} */ const parser = pool.require('parser'); /** @type {transformer} */ const transformer = pool.require('transformer'); const bar = parser.parse(); return { bar, parser, transformer }; }, }, accountswitch: { desc: '快捷切换帐号', detectDom: '.main.m_top', disabled: true, dependencies: ['topbar'], func() { /** @type {topbar} */ const topbar = require('topbar'); topbar.transformer.addButton(topbar.bar, { text: '切换帐号', position: 'left', type: 'span', callback() { console.log('这里假装切换了一下帐号'); }, index: 1, }); }, } }; default_pool.catch_errors = true; loadFuncs(functions); }) ();