// ==UserScript== // @name 轻小说文库+ // @namespace https://greasyfork.org/users/667968-pyudng // @version 2.alpha.19.6 // @description 轻小说文库全方位体验改善,涵盖阅读、下载、书架、推荐、书评、账号、页面个性化等各种方面,你能想到的这里都有。没有?欢迎提出你的点子。 // @author PY-DNG // @license GPL-3.0-or-later // @homepageURL https://greasyfork.org/scripts/539514 // @supportURL https://greasyfork.org/scripts/539514/feedback // @match http*://*.wenku8.com/* // @match http*://*.wenku8.net/* // @match http*://*.wenku8.cc/* // @require data:application/javascript,window.setImmediate%20%3D%20window.setImmediate%20%7C%7C%20((f%2C%20...args)%20%3D%3E%20window.setTimeout(()%20%3D%3E%20f(args)%2C%200))%3B // @require https://update.greasyfork.icu/scripts/456034/1651347/Basic%20Functions%20%28For%20userscripts%29.js // @require https://update.greasyfork.icu/scripts/471280/1247074/URL%20Encoder.js // @require https://fastly.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js // @require https://fastly.jsdelivr.net/npm/ejs@3.1.9/ejs.min.js // @require https://fastly.jsdelivr.net/npm/jepub@2.1.4/dist/jepub.min.js // @require https://fastly.jsdelivr.net/npm/xbbcode-parser@0.3.0/xbbcode.min.js // @require https://fastly.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js // @resource vue-js https://unpkg.com/vue@3.5.13/dist/vue.global.prod.js // @resource quasar-icon https://fonts.font.im/css?family=Roboto:100,300,400,500,700,900|Material+Icons // @resource quasar-css https://fastly.jsdelivr.net/npm/quasar@2.15.1/dist/quasar.prod.css // @resource quasar-js https://fastly.jsdelivr.net/npm/quasar@2.15.1/dist/quasar.umd.prod.js // @resource vue-js-bak https://fastly.jsdelivr.net/npm/vue@3.5.13/dist/vue.global.min.js // @resource quasar-icon-bak https://google-fonts.mirrors.sjtug.sjtu.edu.cn/css?family=Roboto:100,300,400,500,700,900|Material+Icons // @resource quasar-css-bak https://unpkg.com/quasar@2.15.1/dist/quasar.prod.css // @resource quasar-js-bak https://unpkg.com/quasar@2.15.1/dist/quasar.umd.prod.js // @connect wenku8.com // @connect wenku8.net // @connect wenku8.cc // @connect 777743.xyz // @icon https://www.wenku8.cc/favicon.ico // @grant GM_getResourceText // @grant GM_registerMenuCommand // @grant GM_setValue // @grant GM_getValue // @grant GM_listValues // @grant GM_deleteValue // @grant GM_addValueChangeListener // @grant GM_removeValueChangeListener // @grant GM_log // @grant GM_addElement // @grant GM_xmlhttpRequest // @grant GM_setClipboard // @downloadURL none // ==/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, XBBCode */ (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: `主页: Greasyfork`, get TechnicalNote() { return `${ GM_info.script.name } 使用自行编写的模块加载系统驱动,由 ${ Object.keys(functions).length } 个模块共同组成;在UI方面,使用了Vue.jsQuasar 框架,并以 Material Symbols & Icons 作为图标库。`; }, 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: `

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

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

`, Options: { Format: { Title: '格式', txt: 'TXT 文档', epub: 'Epub 电子书', image: '仅插图', }, Encoding: { Title: '编码', Caption: '仅对txt文档生效', gbk: 'GBK', utf8: 'UTF-8' }, Filename: '文件名', }, UI: { DownloadButton: '下载', Author: '作者: ', LastUpdate: '最后更新: ', Tags: '作品Tags: ', BookStatus: '状态: ', Intro: '内容简介: ', ContentSelectorTitle: '请选择下载的章节: ', NoContentSelected: '已勾选的下载章节为空', Progress: { Global: '当前步骤 ({CurStep}/{Total}): {Name}', Sub: '当前进度: {CurStep}/{Total}', Ready: '下载器准备就绪', Loading: '正在加载书籍信息...', } }, Steps: { txt: { NovelContent: '下载章节内容', EncodeText: '编码文本', GenerateZIP: '合成ZIP文件', }, image: { NovelContent: '下载章节内容', DownloadImage: '下载图片', GenerateZIP: '合成ZIP文件', }, epub: { NovelContent: '加载章节内容和图片', GenerateEpub: '合成Epub文件', }, }, }, Autovote: { Add: '添加到自动推书', Added: '添加成功', AddSuccess: '将 {Name} 添加到了每日自动推书中', AddDuplicate: '其实 {Name} 已经在自动推书列表中了', Configure: '自动推书配置', VoteStart: '开始自动推书...', VoteEnd: '自动推书完成', VoteDetail: '详情', UI: { Title: '自动推书配置', Votes: '每日推荐票数', TimeAdded: '添加时间: ', VotedCount: '累计自动推书: ', TotalVotes: '已分配的总票数: ', TotalBooks: '总书籍数量: ', ConfirmRemove: { Title: '从自动推书中移除书籍', Message: '确实要将 {Name} 从自动推书中移除吗?移除后,以前的推书记录也将一同被删除。', Ok: '确定', Cancel: '还是算了', }, }, Settings: { Title: '自动推书', Configuration: '自动推书配置', Configure: '编辑', Enabled: '启用自动推书', EnabledCaption: '关闭后将不再每日自动推书、不显示相关UI,但推书配置和记录仍将保留', } }, ReviewCollection: { CollectionTitle: '书评收藏', Add: '收藏书评', Remove: '取消收藏书评', Added: '已添加到书评收藏', Removed: '已取消收藏此书评', HasNewFloors: '[有更新]', 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: `
[${ GM_info.script.name }]本书已被屏蔽
双击临时显示本书
`, 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: '已读取草稿', PreviewButton: '预览', PreviewDialog: { Ok: '确认', }, }, }, '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: `主頁: Greasyfork`, get TechnicalNote() { return `${ GM_info.script.name } 使用自行編寫的模組載入系統驅動,由 ${ Object.keys(functions).length } 個模組共同組成;在UI方面,使用了Vue.jsQuasar 框架,並以 Material Symbols & Icons 作為圖示庫。`; }, 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: `

本書輕小說文庫連結:{URL}
Epub電子書由${GM_info.script.name}合成。

本資源僅供試讀,如喜愛本書,請購買正版。

`, Options: { Format: { Title: '格式', txt: 'TXT 文件', epub: 'Epub 電子書', image: '僅插圖', }, Encoding: { Title: '編碼', Caption: '僅對txt文件生效', gbk: 'GBK', utf8: 'UTF-8' }, Filename: '檔案名稱', }, UI: { DownloadButton: '下載', Author: '作者: ', LastUpdate: '最後更新: ', Tags: '作品Tags: ', BookStatus: '狀態: ', Intro: '內容簡介: ', ContentSelectorTitle: '請選擇下載的章節: ', NoContentSelected: '已勾選的下載章節為空', Progress: { Global: '當前步驟 ({CurStep}/{Total}): {Name}', Sub: '當前進度: {CurStep}/{Total}', Ready: '下載器準備就緒', Loading: '正在載入書籍資訊...', } }, Steps: { txt: { NovelContent: '下載章節內容', EncodeText: '編碼文字', GenerateZIP: '合成ZIP文件', }, image: { NovelContent: '下載章節內容', DownloadImage: '下載圖片', GenerateZIP: '合成ZIP文件', }, epub: { NovelContent: '載入章節內容和圖片', GenerateEpub: '合成Epub文件', }, }, }, Autovote: { Add: '新增到自動推書', Added: '新增成功', AddSuccess: '將 {Name} 新增到了每日自動推書中', AddDuplicate: '其實 {Name} 已經在自動推書列表中了', Configure: '自動推書設定', VoteStart: '開始自動推書...', VoteEnd: '自動推書完成', VoteDetail: '詳情', UI: { Title: '自動推書設定', Votes: '每日推薦票數', TimeAdded: '新增時間: ', VotedCount: '累計自動推書: ', TotalVotes: '已分配的總票數: ', TotalBooks: '總書籍數量: ', ConfirmRemove: { Title: '從自動推書中移除書籍', Message: '確實要將 {Name} 從自動推書中移除嗎?移除後,以前的推書記錄也將一同被刪除。', Ok: '確定', Cancel: '還是算了', }, }, Settings: { Title: '自動推書', Configuration: '自動推書設定', Configure: '編輯', Enabled: '啟用自動推書', EnabledCaption: '關閉後將不再每日自動推書、不顯示相關UI,但推書設定和記錄仍將保留', } }, ReviewCollection: { CollectionTitle: '書評收藏', Add: '收藏書評', Remove: '取消收藏書評', Added: '已新增到書評收藏', Removed: '已取消收藏此書評', HasNewFloors: '[有更新]', 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: `
[${ GM_info.script.name }]本書已被封鎖
雙擊臨時顯示本書
`, 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: '已讀取草稿', PreviewButton: '預覽', PreviewDialog: { Ok: '確認', }, }, }, 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, }, }], // BBCode转换器所用文库表情代码-图片src数据 WenkuEmojis: [ ['/:O', '1.gif', '惊讶'], ['/:~', '2.gif', '撇嘴'], ['/:*', '3.gif', '色色'], ['/:|', '4.gif', '发呆'], ['/8-)', '5.gif', '得意'], ['/:LL', '6.gif', '流泪'], ['/:$', '7.gif', '害羞'], ['/:X', '8.gif', '闭嘴'], ['/:Z', '9.gif', '睡觉'], ['/:`(', '10.gif', '大哭'], ['/:-', '11.gif', '尴尬'], ['/:@', '12.gif', '发怒'], ['/:P', '13.gif', '调皮'], ['/:D', '14.gif', '呲牙'], ['/:)', '15.gif', '微笑'], ['/:(', '16.gif', '难过'], ['/:+', '17.gif', '耍酷'], ['/:#', '18.gif', '禁言'], ['/:Q', '19.gif', '抓狂'], ['/:T', '20.gif', '呕吐'] ], // 书评图片重试缩放间隔 ReviewResizeInterval: 500, // 自适应高度编辑器的最大和最小高度 EditorHeight: { Min: 150, Max: 500, }, // 屏蔽书籍临时展示时长 BlockingBookTempShowTime: 5000, // 书评收藏楼层更新自动检测最短时间间隔 ReviewUpdateMinCheckInterval: 10 * 60 * 1000, // 书评草稿保存最大条目数量 UBBEditorMaximumDraft: 30, }, }; const functions = { utils: { /** @typedef {Awaited>} 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,因此', // 编辑器和表单 editor_html, ].join('\n'); */ const body_html = [ // 文库自带CSS '', // 深色模式CSS darkmode.getPageCSS(url).map(css => ``).join('\n'), // Material Icons ``, // JS依赖 ``, // 编辑器和表单 editor_html, // UBBEditor ``, ].join('\n'); // 深色模式 const body_class = darkmode.actual_enabled ? 'plus-darkmode' : ''; const html = ` ${body_html} `; 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: `
`, 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: {}, } } } }, bbcode: { desc: '基于XBBCode-Parser的适用于文库的BBCode解析器', dependencies: ['logger', 'utils'], /** @typedef {Awaited>} bbcode */ async func() { /** @type {logger} */ const logger = require('logger'); /** @type {utils} */ const utils = require('logger'); // 移除预定义tags const tags = XBBCODE.tags(); Object.keys(tags).forEach(t => delete tags[t]); // 定义文库格式tags /** @typedef {(params: string, content: string) => string} TagFunction */ /** @typedef {{ openTag: TagFunction, closeTag: TagFunction }} TagDefination */ /** * @param {string} tagName * @returns {TagDefination} */ const simpleTag = tagName => ({ openTag(params, content) { return `<${ tagName }>`; }, closeTag(params, content) { return ``; } }); /** @type {Record} */ const ADD_TAGS = { 'size': { openTag(params, content) { return ``; }, closeTag(params, content) { return '' }, }, 'b': simpleTag('b'), 'i': simpleTag('i'), 'u': simpleTag('u'), 'd': simpleTag('del'), 'color': { openTag(params, content) { return ``; }, closeTag(params, content) { return '' }, }, 'wenkucode': { openTag(params, content) { return '
';
                        },
                        closeTag(params, content) {
                            return '
' }, }, 'quote': { openTag(params, content) { return 'Quote:
'; }, closeTag(params, content) { return '
' }, }, 'url': { openTag(params, content) { let url = params.substring(1); url = url.startsWith('http://') || url.startsWith('https://') ? url : 'http://' + url; return ``; }, closeTag(params, content) { return '' }, }, 'email': { openTag(params, content) { return ``; }, closeTag(params, content) { return '' }, }, 'align': { openTag(params, content) { return `

`; }, closeTag(params, content) { return '

'; } }, }; XBBCODE.addTags(ADD_TAGS); /** * 转换文库格式书评源码为html * @param {string} bbcode * @returns {string} */ function bbcode2html(bbcode) { // 解析bbcode // XBBCode默认将code内部嵌套的标签作为源代码内容不解析 // 所以这里手动[code]为[wenkucode]绕过这一特性 bbcode = bbcode.replaceAll('[code]', '[wenkucode]').replaceAll('[/code]', '[/wenkucode]'); const result = XBBCODE.process({ text: bbcode, }); let html = result.html; // 如果解析错误,说明是用户bbcode格式问题,简单log一下错误 result.error && logger.log('Warn', result.errorQueue); // 解析bbcode以外的文库格式 // 图片 const img_matches = html.matchAll(/(\s)(\S*\.(?:jpg|jpeg|png|gif))(\s)/g); for (const match of img_matches) { html = html.replace(match[0], `${match[1]}
${match[3]}`); } // 表情 for (const emoji of CONST.Internal.WenkuEmojis) { const symbol = emoji[0]; const src = `/images/smiles/${ emoji[1] }`; const alt = emoji[2]; const code = `${ alt }`; html = html.replaceAll(symbol, code); } // 换行 html = html.replaceAll('\n', '
'); return html; } /** * 转换文库文库格式富文本html为bbcode * @param {HTMLDivElement | string} html_or_container - 包含html结构的元素或源代码形式的html */ function html2bbcode(html_or_container) { // 思路:为每种类型的文库富文本项目定义一个转换器,提供test方法用于判断给定DOM Node是否为该类型; // 如果是,从该Node开始连续多少Node都是同一个富文本项;再将这些Nodes传入转换器的convert方法转换为bbcode // 转换过程:将所有Node依次传入 /** * @typedef {Object} Converter 代表一种bbcode节点的 节点 => bbcode代码 的转换器 * @property {(node: Node) => number | boolean} test - 返回 从给定节点开始,有多少节点为当前节点类型,如不是当前节点类型则为0;true/false分别可代替1/0 * @property {(node: Node | Node[]) => string} convert - 转换给定节点为bbcode的方法,需先调用test确定节点数量再调用;当数量为1时,直接传入节点,否则传入节点数组 */ /** * 创建仅根据html标签名和bbcode标签名即可进行转换的简单转换器 * @param {string} html_tag * @param {string} bbcode_tag * @returns {Converter} */ const simpleConverter = (html_tag, bbcode_tag) => ({ test(node) { return node.nodeName?.toUpperCase() === html_tag.toUpperCase(); }, /** @param {Node} node */ convert(node) { const t = bbcode_tag.toLowerCase(); return `[${ t }]${ html2bbcode(node) }[/${ t }]`; } }); const converters = { // bbcode部分 size: { /** @param {HTMLSpanElement | Node} node */ test(node) { return node.matches?.('span[style]') && /font-size: \d+px;/.test(node.getAttribute('style')); }, /** @param {HTMLSpanElement} node */ convert(node) { const size = node.style.fontSize.match(/(\d+)px/)[1]; const inner_code = html2bbcode(node); return `[size=${ size }]${ inner_code }[/size]`; } }, b: simpleConverter('b', 'b'), i: simpleConverter('i', 'i'), u: simpleConverter('u', 'u'), d: simpleConverter('del', 'd'), color: { /** @param {HTMLSpanElement | Node} node */ test(node) { return node.matches?.('span[style]') && !!node.style.getPropertyValue('color'); }, /** @param {HTMLSpanElement} node */ convert(node) { // 文库格式color没有#号 const color = node.getAttribute('style').match(/color: #([^;]*);/)[1]; const inner_code = html2bbcode(node); return `[color=${ color }]${ inner_code }[/color]`; } }, code: { /** @param {HTMLDivElement | Node} node */ test(node) { return node.classList?.contains('jieqiCode') && node.firstElementChild.nodeName === 'CODE' && node.firstElementChild.firstElementChild.nodeName === 'PRE'; }, /** @param {HTMLDivElement} node */ convert(node) { const pre = node.firstElementChild.firstElementChild; return `[code]${ html2bbcode(pre) }[/code]`; } }, quote: { // Quote项目由一个#text节点接一个
组成 // 需要注意的是,#text节点可能含有quote之前的纯文本内容,而非仅有"Quote:"这个标记 // 也有可能不含有#text节点,当转换用户选中的部分时,可能没有选中到#text节点而仅选中了后面的div, // 此时也应判定为quote节点 /** @param {Text | HTMLDivElement | Node} node */ test(node) { // Quote:
...
,共两个Node if (node.nodeName === '#text' && node.nodeValue?.endsWith?.('Quote:') && node.nextElementSibling?.classList.contains('jieqiQuote')) { return 2; } if (node.nodeName === 'DIV' && node.classList.contains('jieqiQuote')) { return 1; } return false; }, /** @param {Node | Node[]} node */ convert(node) { if (Array.isArray(node)) { // #text 和
都有 /** @type {HTMLDivElement} */ const container = node[1]; const text_part = node[0].nodeValue.substring(0, node[0].nodeValue.length - 'Quote:'.length); return `[quote]${ html2bbcode(container) }[/quote]`; } else { // 仅有
return `[quote]${ html2bbcode(node) }[/quote]`; } } }, url: { /** @param {HTMLAnchorElement | Node} node */ test(node) { return node.nodeName === 'A' && node.target === '_blank' && node.href.startsWith('http'); }, /** @param {HTMLAnchorElement} node */ convert(node) { const url = node.getAttribute('href'); const inner_code = html2bbcode(node); return `[url=${ url }]${ inner_code }[/url]`; } }, email: { /** @param {HTMLAnchorElement | Node} node */ test(node) { return node.nodeName === 'A' && node.href.startsWith('mailto:'); }, /** @param {HTMLAnchorElement} node */ convert(node) { const email = node.innerText; return `[email]${ email }[/email]`; } }, align: { /** @param {HTMLParagraphElement | Node} node */ test(node) { return node.nodeName === 'P' && node.hasAttribute('align'); }, /** @param {HTMLAnchorElement} node */ convert(node) { const align = node.getAttribute('align'); const inner_code = html2bbcode(node); return `[align=${ align }]${ inner_code }[/align]`; } }, // 非bbcode部分 wenku_image: { /** @param {HTMLDivElement | Node} node */ test(node) { return node.nodeName === 'DIV' && node.classList.contains('divimage') && node.firstElementChild.nodeName === 'IMG'; }, /** @param {HTMLDivElement | Node} node */ convert(node) { // 文库格式图片:直接放图片链接 const url = node.firstElementChild.getAttribute('src'); // 无需左右两侧添加空格,因为被文库识别并渲染为图片,一定自带了空格 return url; } }, wenku_emoji: { /** @param {HTMLImageElement | Node} node */ test(node) { return node.nodeName === 'IMG' && node.getAttribute('src').startsWith('/images/smiles/'); }, /** @param {HTMLImageElement} node */ convert(node) { // 根据表情包src在对照表中查询表情包对应代码 const basename = node.src.substring(node.src.lastIndexOf('/') + 1); const emoji = CONST.Internal.WenkuEmojis.find(emoji => emoji[1] === basename); Assert(emoji, `html2bbcode.emoji: unrecongnized emoji with basename ${ escJsStr(basename) }`, TypeError); const code = emoji[0]; // 文库解析表情代码的方法:直接简单粗暴替换表情包代码为表情包标签 return code; } }, br: { /** @param {HTMLBRElement | Node} node */ test(node) { return node.nodeName === 'BR'; }, /** @param {HTMLBRElement} node */ convert(node) { // 文库会将bbcode中的换行符"\n"渲染为"
\n", // 因此从html转换回来时,如果
后有\n,就丢弃掉
// 仅当只有
后面无\n时,才将
转换为\n const following_newline = node.nextSibling.nodeName === '#text' && node.nextSibling.nodeValue.startsWith('\n'); return following_newline ? '' : '\n'; } }, plain_text: { /** @param {Text | Node} node */ test(node) { // 纯文本节点,且非quote节点 return node.nodeName === '#text' && !converters.quote.test(node); }, /** @param {Text} node */ convert(node) { return node.nodeValue; } }, }; // 将参数转化为container形式 const container = typeof html_or_container === 'string' ? utils.html2elm(`
${html}
`) : html_or_container; const nodes = [...container.childNodes]; // 对container内的每一个节点,进行转换,得到bbcode数组 const node_bbcodes = []; for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; /** @type {Converter} */ const converter = Object.values(converters).find(converter => converter.test(node)); if (converter) { // 正常情况:找到了该节点对应的转换器 const nodes_count = +converter.test(node); const related_nodes = nodes.slice(i, i + nodes_count); const bbcode = nodes_count === 1 ? converter.convert(node) : converter.convert(related_nodes); node_bbcodes.push(bbcode); // 至少使用了一个节点,使用更多节点时,跳过对这些节点的遍历 i += nodes_count - 1; } else { // 异常情况:没有该节点对应的转换器 // 简单提取为innerText,并log错误 const code = node.innerText ?? node.nodeValue; node_bbcodes.push(code); logger.log('Error', `bbcode.html2bbcode: converter not found`, node); } }; // 拼接为总体bbcode返回 const bbcode = node_bbcodes.join(''); return bbcode; } return { bbcode2html, html2bbcode }; } }, ubbeditor: { desc: '编辑器修复与增强', dependencies: ['utils'], params: ['GM_setValue', 'GM_getValue'], /** @typedef {Awaited>} 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} */ async function enhance(form) { await Promise.all([ // 样式表式修复 (async function() { const style = ` textarea[name="pcontent"] { padding: 0.25em; width: 90%; } `; const id = 'plus-ubbeditor-enhance'; const root = form.getRootNode(); if (root.nodeName === '#document') { const head = await detectDom(root, 'head'); addStyle(head, style, id); } else { form.after($$CrE({ tagName: 'style', props: { innerHTML: style, id: id, }, })); } }) (), // 重写插入图片 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 + '
' + 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 + '
' + 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); } } ), // 预览功能 detectDom(form, 'input[name="Submit"]').then( /** @param {HTMLInputElement} input */ input => { input.after($$CrE({ tagName: 'input', props: { type: 'button', value: CONST.Text.UBBEditor.PreviewButton }, styles: { padding: '0 0.5em', marginLeft: '0.5em', }, classes: 'button', listeners: [['click', async e => { /** @type {bbcode} */ const bbcode = await require('bbcode', true); const pcontent = await detectDom(form, '#pcontent'); const ptitle = $(form, '#ptitle'); const code = pcontent.value; const bbhtml = bbcode.bbcode2html(code); const html = `
${ bbhtml }
`; Quasar.Dialog.create({ title: ptitle?.value ?? '', message: html, html: true, ok: { label: CONST.Text.UBBEditor.PreviewDialog.Ok, color: 'primary', }, }); }]] })) } ) ]); } /** * 检查给定链接是否为符合文库书评语法格式的图片链接 * @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} */ 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>} 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>} PageManager */ async func() { const pool_funcs = { parser: { desc: 'DOM解析器', /** @typedef {Awaited>} 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>} 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} 字符串用户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 => `${ tag }`) .join(' '); addStyle(` .plus-tag:is(.plus-darkmode *, :not(.plus-darkmode *)) { color: var(--q-primary); } `); } }, details: { desc: '添加查看详情数据的功能', 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 = `
${ message.replaceAll('\n', '
') }
`; 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>} 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>} 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} */ 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} */ 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} */ 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} */ 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} 新名字,或者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>} 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>} 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>} 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 = `
${ CONST.Text.ReadLater.Title }
`; $('.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 = `
${ book.name } `; book_container.append($$CrE({ tagName: 'div', props: { innerHTML: `close`, }, 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>} downloader */ async func() { /** @type {utils} */ const utils = require('utils'); /** @type {api} */ const api = require('api'); const pool_funcs = { core: { desc: '下载器核心:下载器界面、功能', /** @typedef {Awaited>} 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} detail.options * @param {number[]} detail.chapters * @returns {any} */ const pool_funcs = { gui: { /** @typedef {Awaited>} gui */ async func() { const container = $CrE('div'); const UI = CONST.Text.Downloader.UI; container.innerHTML = ` ${ CONST.Text.Downloader.Title }
{{ info.meta.Title.value }}
${ UI.Author }{{ info.meta.Author }}
${ UI.BookStatus }{{ info.meta.BookStatus }}
${ UI.LastUpdate }{{ info.meta.LastUpdate }}
${ UI.Tags }{{ info.meta.Tags }}
${ UI.Intro }{{ info.intro }}
{{ option.label }} {{ option.caption }}
${ UI.ContentSelectorTitle }
${ replaceText( UI.Progress.Global, { '{Total}': '{{ progress.total }}', '{CurStep}': '{{ Math.min(progress.total, progress.finished + 1) }}', '{Name}': '{{ sub_progress.name ?? "" }}', } ) } ${ replaceText( UI.Progress.Sub, { '{Total}': '{{ sub_progress.total }}', '{CurStep}': '{{ Math.min(sub_progress.total, sub_progress.finished + 1) }}', } ) } {{ loading ? "${ UI.Progress.Loading }" : "${ UI.Progress.Ready }" }}
`; 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>} 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, manager: InstanceType, filename: string }} */ const pool_funcs = { txt: { /** @typedef {Awaited>} 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>} 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(/([^<]+?)/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>} 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} */ 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(/([^<]+?)/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 => `

${line}

`).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) => { // 创建分卷层级的 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 = ` ${ utils.htmlEncode(volume.name) } `; $(xml, 'navMap').append(volume_nav); // 将该分卷所属所有章节的移动到分卷内 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} 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>} 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' }], detectDom: '.main.m_foot', 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>} gui */ async func() { /** @type {core} */ const core = pool.require('core'); const container = $CrE('div'); const UI = CONST.Text.Autovote.UI; container.innerHTML = ` ${ UI.Title }
${ UI.TimeAdded }{{ new Date(book.time_added).toLocaleDateString() }}
${ UI.VotedCount }{{ book.voted }}
${ UI.TotalVotes }{{ total_votes }} ${ UI.TotalBooks }{{ total_books }}
`; document.body.append(container); let instance; const app = Vue.createApp({ data() { return { visible: false, books: core.list(), }; }, computed: { /** * 根据书籍aid自动合成的书籍信息页链接 * @type {Record} */ 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>} { [书籍字符串aid]: (推书结果文档中的block)[] } */ async function doAutovote() { const record = getRecord(); const books = core.list(); // 筛选出今日未推完的书,并计算剩余推书票数 /** @type {Record} 未推完的书及其剩余推书票数 */ 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} 返回的页面中的.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 = ` `; }, }, */ 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: `
${ CONST.Text.ReviewCollection.CollectionTitle }
    `, }, 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>} 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} 切换后是否为已收藏状态 */ 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} */ 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: '书评打开尾页', async func() { // 添加按钮的页面 const working_pages = [ // 书籍信息页 { 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' }, ]; // 添加[打开尾页]按钮 FunctionLoader.testCheckers(working_pages) && 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>} 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(`
    ${ CONST.Text.Blocking.BookBlocked }
    `); 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>} 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-blocked-element { position: relative; } `, '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为relative方便遮罩定位 div.classList.add('plus-blocked-element'); // 创建标准遮罩 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.classList.remove('plus-blocked-element'); $(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>} gui */ async func() { const container = $CrE('div'); const UI = CONST.Text.Blocking.UI; container.innerHTML = ` ${ UI.Title } {{ item_info[i].text }} ${ UI.TimeAdded }{{ item_info[i].time }} `; 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>} gui */ async func() { const container = $CrE('div'); const UI = CONST.Text.Reader.UI; container.innerHTML = ` ${ UI.Enabled } ${ UI.EnabledCaption } ${ UI.FontFamily } ${ UI.FontFamilyCaption } ${ UI.FontSize } ${ UI.Color } ${ UI.ColorCaption } `; 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: ` `, 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: ` `, 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>} 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>} 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>} 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>} 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', 'utils'], async func() { /** @type {topbar} */ const topbar = require('topbar'); /** @type {utils} */ const utils = require('utils'); const pool_funcs = { gui: { func() { // 在document.body内创建一个Div作为Vue应用mount的根元素,然后让Quasar2在这个元素上创建应用,并在其中通过.innerHTML书写模板的方式创建一个弹窗Dialog,弹窗内容为一个帐号登录界面 } } }; const { pool, promise } = utils.loadFuncInNewPool(pool_funcs); await promise; topbar.transformer.addButton(topbar.bar, { text: '切换帐号', position: 'left', type: 'span', callback() { console.log('这里假装切换了一下帐号'); }, index: 1, }); }, } }; default_pool.catch_errors = true; loadFuncs(functions); }) ();