// ==UserScript== // @name 手机百度贴吧自动展开楼层 // @namespace http://tampermonkey.net/ // @homepage https://greasyfork.org/scripts/445657 // @version 3.2 // @description 有时候用手机的浏览器打开百度贴吧,只想看一眼就走,并不想打开APP,这个脚本用于帮助用户自动展开楼层。注意:只支持手机浏览器,测试环境为Iceraven+Tampermonkey // @author voeoc // @match https://tieba.baidu.com/* // @match https://jump2.bdimg.com/* // @match https://tiebac.baidu.com/* // @exclude https://*/index // @exclude https://*/index* // @exclude https://*/f?*kw=* // @connect https://tieba.baidu.com/mg/o/getFloorData // @connect https://jump2.bdimg.com/mg/o/getFloorData // @connect https://tiebac.baidu.com/mg/o/getFloorData // @icon https://tieba.baidu.com/favicon.ico // @grant unsafeWindow // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_listValues // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM_getResourceText // @license MIT // @downloadURL none // ==/UserScript== (function () { 'use strict'; const STR_GMKEY_IS_DEBUG = "VOEOC_GMKEY_IS_DEBUG"; const STR_ID_LZLPAGE = "VOEOC-ID-LZLPAGE"; const STR_ID_LZLPAGEBACKGROUND = "VOEOC-ID-LZLPAGEBACKGROUND"; const STR_CLASSNAME_LZLPAGEIFRAME = "VOEOC-CLASSNAME-LZLPAGEIFRAME"; const STR_VOEOCMARK = "VOEOCMARK"; // 临时标记 const STR_DEBUG_LABEL_ERROR = "error"; const STR_LZL_PAGE_TRANSITION_DURATION = "0.2s"; const GM = { /**@ts-ignore @type {Window} */ unsafeWindow: unsafeWindow, /**@ts-ignore @type {function(string):HTMLStyleElement} */ addStyle: GM_addStyle, /**@ts-ignore @type {function(any):function():void} */ xmlhttpRequest: GM_xmlhttpRequest, /**@ts-ignore @type {function(string, any):any} */ getValue: GM_getValue, /**@ts-ignore @type {function(string, any):void} */ setValue: GM_setValue, /**@ts-ignore @type {function():void} */ listValues: GM_listValues, /**@ts-ignore @type {function(string, function(), string):number} */ registerMenuCommand: GM_registerMenuCommand, /**@ts-ignore @type {function(string):void} */ unregisterMenuCommand: GM_unregisterMenuCommand, /**@ts-ignore @type {function(string):string} */ getResourceText: GM_getResourceText, } let IS_DEBUG = GM.getValue(STR_GMKEY_IS_DEBUG, false); // 调试信息开关。需要手动编辑油猴插件的存储数据 const VOEOC_REG = { // 自定义正则 POSTPAGE: RegExp(`postPage\?(?=.*tid\=)(?=.*postAuthorId\=)(?=.*forumId\=)`, 'i'), // 评论页url LZLPAGE: RegExp(`lzlPage\?(?=.*floor\=)(?=.*pid\=)`, 'i'), // 楼中楼页url PBDATA: RegExp(`getPbData\?.*pn\=.*`, 'i'), // 通用页面数据url } const PAGE_TYPE = { // 页面类型 UNKNOW: -1, // 未知 MAINPAGE: 0, // 主页 POSTPAGE: 1, // 评论页 LZLPAGE: 2, // 楼中楼页 TIEBAPAGE: 3, // 贴吧主页 }; // 简单判断当前页面的类型 function getPageType() { /** * 网址解析 * 贴吧页,样例:https://tieba.baidu.com/f?kw=百度 * 帖子页,样例: * 1. https://tieba.baidu.com/f?kz=111111111 * 2. https://tieba.baidu.com/p/111111111 * hash在帖子中位置判断(样例) * 1.评论页(展开页面):#/postPage?tid=111111111&postAuthorId=9468343&forumId=141431&locateConfig=[]&source=a0-bpb-c111111111-d0-e0 * 2.楼中楼页:#/lzlPage?tid=111111111&pid=897196650&floor=2&postAuthorId=9468343&forumId=141431 */ // let isTiePage = false; if (RegExp(`^/f`).test(window.location.pathname)) { if (GET_URL_ATTR(window.location.href, "kw")) { // 贴吧主页、或者某一个吧的页面 return PAGE_TYPE.TIEBAPAGE; } else if (GET_URL_ATTR(window.location.href, "kz")) { // 贴子页 isTiePage = true; } } else if (RegExp(`^/p`).test(window.location.pathname)) { // 贴子页 isTiePage = true; } if (isTiePage) { let hash = window.location.hash; if (hash === "" || hash === "#/") { return PAGE_TYPE.MAINPAGE; } else if (VOEOC_REG.POSTPAGE.test(hash)) { return PAGE_TYPE.POSTPAGE; } else if (VOEOC_REG.LZLPAGE.test(hash)) { return PAGE_TYPE.LZLPAGE; } } DEBUGLOG("未知页面", STR_DEBUG_LABEL_ERROR); return PAGE_TYPE.UNKNOW; } // 提前处理评论页,加快速度 if (getPageType() == PAGE_TYPE.POSTPAGE) { // 如果当前刷新加载的是评论页,则需要先打开主页面获取数据 window.location.hash = ""; window.location.reload(); } // 单例管理器,将单例实例独立出去 class SingleInstanceManager { static #INSTANCE_LIST = {}; /** * * @param {any} classT 传入相关类型 */ static ORDER_SINGLE(classT) { let instance = SingleInstanceManager.#INSTANCE_LIST[classT]; if (!instance) { instance = new classT(); SingleInstanceManager.#INSTANCE_LIST[classT] = instance; } classT.getInstance = function () { return instance; } } /** * 对于每个classT,只能运行一次,应在对应的类构造函数加入 * @param {any} classT 传入相关类型 */ static CHECK_SINGLE_INSTANCE(classT) { ASSERT(!SingleInstanceManager.#INSTANCE_LIST[classT], "非法构建,只允许一个实例,请调用getInstance()获取对象"); } } class SWITCHLABEL { static LABEL_TYLE = { UNKNOW: 0, SWITCH: 1 // 切换开关,实际控制的也是一个布尔值 } /** * * @param {number} type * @param {string} prefix * @param {Array} switchlist */ constructor(type, prefix, switchlist) { this.type = type; this.prefix = prefix; this.switchlist = switchlist; } } class SettingValue { static TYPE = { NUMBER: "number", BOOLEAN: "boolean", STRING: "string", UNDEFINED: "undefined", } static LINK_TYPE = { UNKNOW: -1, SWITCH: 0, // 将自己作为开关,控制连接的对象 CONTROLLED: 1, // 被另一个开关控制 OPPOSITE: 2, // 与其他连接值始终相反 SAME: 3, // 与其他连接值始终相同 } #key; get key() { return this.#key; } #value; set value(newValue) { let newValueType = typeof newValue; ASSERT(newValueType == this.#defaultValueType, `类型不匹配,需要${this.#defaultValueType}却传入${newValueType}`); GM.setValue(this.#key, newValue); if (this.#value == newValue) { return; } this.#value = newValue; if (this.#linkObj && this.#defaultValue == SettingValue.TYPE.BOOLEAN) { // 关联 if (this.#linkType == SettingValue.LINK_TYPE.OPPOSITE) { if (this.#linkObj.value == this.#value) { this.#linkObj.value = !this.#value; } } else if (this.#linkObj.value != this.#value) { this.#linkObj.value = this.#value; } } } get value() { return this.#value; } #defaultValue; get defaultValue() { return this.#defaultValue; } #defaultValueType; get defaultValueType() { return this.#defaultValueType; } /**@type { {min: number;max: number;} | undefined} 数字范围 */ #range; get range() { return this.#range; } checkRange(value, self = this) { if (!self.#range || !self.#range.min || !self.#range.max) { return true; } return self.#range.min <= value && value <= self.#range.max; } /**@type {SWITCHLABEL | string} 标签说明*/ #label; get label() { return this.#label; } /** @type SettingValue */ #linkObj; get linkObj() { return this.#linkObj; } #linkType = SettingValue.LINK_TYPE.UNKNOW; get linkType() { return this.#linkType; } /** * 与另一个设置值对象的连接,任意一个对象执行一次link即可, * 1.当自己为布尔类型时,而令一个对象非布尔类型,将自身作为这另一个连接对象的开关; * 2.当自己为布尔类型时,而令一个对象也为布尔类型,则值变更时同时变更,如果相反开关存在则他们一直会相反; * * @param {SettingValue} otherObj * @param {number} linkType */ linkOther(otherObj, linkType = SettingValue.LINK_TYPE.UNKNOW) { this.#linkObj = otherObj; this.#linkType = linkType; if (otherObj.linkObj != this) { switch (linkType) { case SettingValue.LINK_TYPE.SWITCH: otherObj.linkOther(this, SettingValue.LINK_TYPE.CONTROLLED); break; case SettingValue.LINK_TYPE.CONTROLLED: otherObj.linkOther(this, SettingValue.LINK_TYPE.SWITCH); break; default: otherObj.linkOther(this, linkType); break; } } } /** @type {function(any):any} */ parseFunc; // 自带解析函数,将外部数据转换 /** * * @param {string} key 键 * @param {any} defaultValue 默认值 * @param {SWITCHLABEL | string} label 选择器标签 * @param { {min: number;max: number;} } [range] 数字的范围 */ constructor(key, defaultValue, label, range = undefined) { let self = this; self.#key = key; self.#defaultValue = defaultValue; self.#defaultValueType = typeof defaultValue; self.#range = range; self.#label = label; switch (self.#defaultValueType) { case SettingValue.TYPE.STRING: self.parseFunc = String; break; case SettingValue.TYPE.NUMBER: self.parseFunc = parseInt; break; case SettingValue.TYPE.BOOLEAN: self.parseFunc = Boolean; break; default: throw new Error("不支持该类型"); break; } self.loadValue(); } loadValue(self = this) { let gValue = GM.getValue(self.#key, self.#defaultValue); if (typeof self.#defaultValue !== typeof gValue || Number.isNaN(gValue) || !self.checkRange(gValue)) { DEBUGLOG(`存储的值(${gValue})格式不对,执行重设`, STR_DEBUG_LABEL_ERROR); // 存储的值格式不对,执行重设 self.value = self.#defaultValue; } else { // 读取正常值 self.#value = gValue; } } } const HTML_SVG_CLOSE_BTN = ``; const HTML_SVG_RELOAD_BTN = ``; const HTML_SVG_SETTING_BTN = ``; const HTML_SVG_DOWN_BTN = ``; // 载入配置 const SETTINGS_DATA = { CHTML_0: `设置(手动刷新后生效)
`, // 设置展开按钮点击动作 isClickToExpandLzlPage: new SettingValue("VOEOC_GMKEY_isClickToExpandLzlPage", true, new SWITCHLABEL(SWITCHLABEL.LABEL_TYLE.SWITCH, "单击展开按钮时", ["原地展开", "弹出楼中楼页"])), // 设置展开按钮长按动作 isLongClickToExpandLzlPage: new SettingValue("VOEOC_GMKEY_isLongClickToExpandLzlPage", false, new SWITCHLABEL(SWITCHLABEL.LABEL_TYLE.SWITCH, "长按展开按钮时", ["原地展开", "弹出楼中楼页"])), CHTML_1: "
", /**@todo*/ //isSeeLzOnly: new SettingValue("VOEOC_GMKEY_isCanSeeLzOnly", true, "只看楼主"), isAutoExpand: new SettingValue("VOEOC_GMKEY_isAutoExpand", true, "载入时自动展开楼中楼"), // 自动展开的开关 isRemaindAutoExpand: new SettingValue("VOEOC_GMKEY_isRemaindAutoExpand", true, "剩余评论过少时展开楼中楼"), // 剩余评论过少时自动展开的开关 CHTML_2: "
", eachExpandSize: new SettingValue("VOEOC_GMKEY_eachExpandSize", 10, "楼中楼一次展开的行数", { min: 10, max: 30 }), // 每次展开的评论数量,至少为10,少于10按10计算 autoExpandNum: new SettingValue("VOEOC_GMKEY_autoExpandNum", 1, "自动展开次数", { min: 1, max: 5 }), // 每次展开的评论数量,至少为10,少于10按10计算 remaindAutoExpandSize: new SettingValue("VOEOC_GMKEY_remaindAutoExpandSize", 7, "剩余评论自动展开", { min: 0, max: 20 }), // 当剩余评论少于这个数时,执行自动展开 lzlCacheSize: new SettingValue("VOEOC_GMKEY_lzlCacheSize", 10, "楼中楼页缓存数量", { min: 1, max: 40 }), // 楼中楼页缓存的大小 } SETTINGS_DATA.isRemaindAutoExpand.linkOther(SETTINGS_DATA.remaindAutoExpandSize, SettingValue.LINK_TYPE.SWITCH); SETTINGS_DATA.isAutoExpand.linkOther(SETTINGS_DATA.autoExpandNum, SettingValue.LINK_TYPE.SWITCH); //settingsData.isClickToExpandLzlPage.linkOther(settingsData.isLongClickToExpandLzlPage, SettingValue.LINK_TYPE.OPPOSITE); function CREATE_CSS_TEXT(str) { return str.replace(/\n/g, ""); } const STR_CSS_REMOVE = CREATE_CSS_TEXT( ` .comment-box, .only-lz, .nav-bar-bottom, .open-app, .more-image-desc { display: none !important; } .logo-wrapper { visibility: hidden !important; pointer-events: none !important; height: 0; } .open-app-text { display: none !important; } `); const STR_CSS_MAINPAGE = CREATE_CSS_TEXT( ` .open-app-text-real { display: block !important; -webkit-box-flex: 0; -webkit-flex: none; -ms-flex: none; flex: none; font-size: .13rem; color: #614ec2; } .open-app-text-real.error { color: #ff3366 !important; text-decoration: line-through; } @keyframes rotate3d { 0%{-webkit-transform:rotate3d(1, 0, 0, 0deg);} 25%{-webkit-transform:rotate3d(1, 0, 0, 90deg);} 50%{-webkit-transform:rotate3d(1, 0, 0, 180deg);} 75%{-webkit-transform:rotate3d(1, 0, 0, 270deg);} 100%{-webkit-transform:rotate3d(1, 0, 0, 360deg);} } .open-app-guide.loading { animation: rotate3d 0.5s linear infinite; pointer-events: none; } .voeoc-swal-input { position: center; } .voeoc-swal-input-checkbox { height: 0.15rem; width: 0.15rem; } .voeoc-swal-range { width: 100%; } .voeoc-swal-range-label { font-size: 0.13rem; } div.disabled > .voeoc-swal-range-label { color: #939393ab;; } .switch_label { text-decoration: line-through; color: gray; padding: 0.05rem; border-radius: 0.05rem; line-height: 0.15rem !important; } .switch_label.on { text-decoration: unset; color: #eeeded; background-color: #3480e5ab; } .switch_checkbox {} .switch_checkbox > * { display: inline-block; } .swal2-content { font-size: 0.15rem !important; line-height: 0.4rem !important; color: gainsboro; } .swal2-popup { font-size: unset; } #${STR_ID_LZLPAGE} { position: fixed; overscroll-behavior: none; width: 100%; z-index: 999; height: 0; visibility: hidden; transition: visibility ${STR_LZL_PAGE_TRANSITION_DURATION}; overflow: scroll; } #${STR_ID_LZLPAGE}::-webkit-scrollbar { width: 0 !important } #${STR_ID_LZLPAGEBACKGROUND} { position: fixed; width: 100%; height: 200%; background-color: #00000077; z-index: -1; opacity: 0; transition: opacity ${STR_LZL_PAGE_TRANSITION_DURATION}; } .${STR_CLASSNAME_LZLPAGEIFRAME} { position: fixed; width: 100%; height: 0%; background-color: #ffffff; bottom: 0; transition: height ${STR_LZL_PAGE_TRANSITION_DURATION}; overflow: hidden; } .lzl-nav-btn.lzl-reload-btn { position:fixed; margin: 0.1rem; top:20%; margin-left: -0.5rem; transition: margin-left ${STR_LZL_PAGE_TRANSITION_DURATION}; } #${STR_ID_LZLPAGE}.show { display: block; height: 100%; visibility: visible; } #${STR_ID_LZLPAGE}.loading, #${STR_ID_LZLPAGE}.loading > .${STR_CLASSNAME_LZLPAGEIFRAME} { pointer-events: none; } #${STR_ID_LZLPAGE}.show > .${STR_CLASSNAME_LZLPAGEIFRAME}.show { height: 80%; } #${STR_ID_LZLPAGE}.show > #${STR_ID_LZLPAGEBACKGROUND} { opacity: 1; } #${STR_ID_LZLPAGE}.show > .lzl-reload-btn { margin-left: 0.1rem; } .lzl-nav-btn { width: .32rem; height: .32rem; display: flex; background-color:#d0d0d04a; border-radius:50%; } .lzl-nav-btn > svg{ position: relative; top: 50%; left: 50%; transform: translate(-50%, -50%); } @keyframes rotate { 0%{-webkit-transform:rotate(0deg);} 25%{-webkit-transform:rotate(90deg);} 50%{-webkit-transform:rotate(180deg);} 75%{-webkit-transform:rotate(270deg);} 100%{-webkit-transform:rotate(360deg);} } #${STR_ID_LZLPAGE}.loading > .lzl-reload-btn { animation: rotate 0.2s linear infinite; pointer-events: none; } #${STR_ID_LZLPAGE}.error > .lzl-reload-btn { background-color: #ff000070 !important; } .slide-down-btn{ position: fixed; top: 20%; left: 50%; margin-left: -0.32rem; opacity: 0; transition: opacity,margin-top,background-color 0.1s; } .slide-down-btn.show{ opacity: 1; } .slide-down-btn.confirm{ background-color: #0000004a; } .voeoc-setting-dialog { height: 0; width: 100%; position: fixed; z-index: 9999; background-color: #000000d1; color: #eee; overflow-y: auto; justify-content: center; top: 0; right: 0; bottom: 0; left: 0; flex-direction: row; display: flex; overflow-x: hidden; transition: background-color .1s; overscroll-behavior: none; } .voeoc-setting-dialog.show{ height: 100%; } .voeoc-settting-content { width: 100%; height: fit-content; min-height: 100%; padding-bottom: 0.5rem; } .voeoc-settting-value-list { text-align: left; padding: 0.2rem; line-height: 0.4rem !important; } .voeoc-setting-button-pad { margin:0 auto; width: fit-content; float: right; margin-right: 0.13rem; } .voeoc-setting-button-pad > * { display: inline-block; padding: 0.01rem; margin: 0.05rem; } .voeoc-setting-button { background: #7d7d7d; width: fit-content; padding-inline: 0.3rem; padding-block: 0.05rem; border-radius: 0.03rem; } `); const HISTORY_STATE = { LZLPAGE: { title: "楼中楼回复", id: "1" }, SETTINGDIALOG: { title: "设置窗口", id: "2" }, } class VoeocDialog { /** * @protect * @type {{title: string,id: string}} */ historyState; /** * @protect * @type {HTMLDivElement} */ dialogNode; show(self = this) { self.dialogNode.classList.add("show"); } hide(self = this) { self.dialogNode.classList.remove("show"); } isShown(self = this) { return self.dialogNode.classList.contains("show"); } open(self = this) { HistoryStateManager.getInstance().showNewDialog(self); } close() { HistoryStateManager.getInstance().closeLastDialog(); } /** * @param {{ title: string; id: string; }} historyState * @param {HTMLDivElement} dialogNode */ setState(historyState, dialogNode) { this.historyState = historyState; this.dialogNode = dialogNode; } } class HistoryStateManager { /**@type {Stack}*/ #showDialogList; /**@returns {HistoryStateManager} */ static getInstance() { throw new Error("请使用SingleInstanceManager初始化"); } constructor() { SingleInstanceManager.CHECK_SINGLE_INSTANCE(HistoryStateManager); } init(self = this) { self.#showDialogList = new Stack(); // 监听后退按钮 window.addEventListener("popstate", function (event) { if (self.#showDialogList.isEmpty()) { return; } DEBUGLOG(window.history.state.id); if (self.#showDialogList.peek().isShown()) { self.closeLastDialog(); } }, false); } /** * * @param {VoeocDialog} voeocDialog */ showNewDialog(voeocDialog, self = this) { if (!voeocDialog.isShown()) { voeocDialog.show(); window.history.pushState(voeocDialog.historyState, voeocDialog.historyState.title); self.#showDialogList.push(voeocDialog); } } closeLastDialog(self = this) { if (self.#showDialogList.isEmpty()) { return; } let hiddenDialog = self.#showDialogList.peek(); if (hiddenDialog.isShown()) { if (window.history.state.id == hiddenDialog?.historyState.id) { // 当前历史记录的state没有改变,说明事件不是后退键触发的 window.history.back(); // 模拟后退键,改变当前页面的state return; } // 正式隐藏 hiddenDialog?.hide(); self.#showDialogList.pop(); } } } // 获取屏幕DPI const DPI = (() => { let DPI = { x: 160, y: 160, }; let tmpNode = document.createElement("DIV"); tmpNode.style.cssText = "width:1in;height:1in;position:absolute;left:0px;top:0px;z-index:99;visibility:hidden"; document.body.appendChild(tmpNode); DPI.x = tmpNode.offsetWidth; DPI.y = tmpNode.offsetHeight; document.body.removeChild(tmpNode); return DPI; })(); /** * 将厘米转换为像素 * @param {number} cm 厘米数 * @param {number} dpi 每英寸像素点数 * @returns 像素 */ function CM2PX(cm, dpi = DPI.y) { return (cm * dpi) / 25.4; } /** * 自动输出错误 * @param {function() : void} func * @param {string} [msg] */ function AUTO_CATCH_ERROR(func, msg = undefined) { try { func(); } catch (e) { if (!msg) { msg = e; } DEBUGLOG(msg, STR_DEBUG_LABEL_ERROR); } } /** * 当不符合条件时自动中断 * @param {any} condition 条件 * @param {string} msg 错误信息 */ function ASSERT(condition, msg) { if (!condition) { throw new Error(`${msg}`) } } /** * 输出调试信息 * @param {any} msg 信息 * @param {string} label 标签 */ function DEBUGLOG(msg, label = "") { if (!IS_DEBUG) { return; } let outputFunc = console.log; if (label == STR_DEBUG_LABEL_ERROR) { outputFunc = console.error; } outputFunc(`voeoc(DEBUG)<${label}>: ${msg}`); } /** * 阻止事件冒泡 * @param {Event} event */ function STOPPROPAGATION(event) { event = event || window.event; if (event.stopPropagation) { event.stopPropagation(); } else { event.cancelBubble = true; } return false; } function REGISTER_MENUCOMMAND() { let menuId = GM.registerMenuCommand(`设置`, SettingDialog.OPEN_DIALOG, "VOEOC_MENU_ACCESS_KEY_SETTINGS"); } /** * * @param {string} selector css选择器 * @param {number} TIME_OUT 查找的次数 * @param {function(string): any} searchFunc 搜索函数 */ function WAIT_ELEMENT_LOADED_ASYNC(selector, TIME_OUT = 30, searchFunc = document.querySelector.bind(document)) { return new Promise((resolve, reject) => { let findTimeNum = 0; // 记录查找的次数 let timer = setInterval(() => { let element = searchFunc(selector); DEBUGLOG(`${selector}=${element}`, "waitElementLoaded"); if (element != null) { // 清除定时器 clearInterval(timer); resolve(element); return; } findTimeNum++; if (TIME_OUT < findTimeNum) { // 超过设定次数 // 清除定时器 clearInterval(timer); reject(new Error(`${selector}=${element}`)); } }, 200); }); } /** * * @param {string} selector css选择器 * @param {function(HTMLElement): void} func 找出元素后的后续操作 * @param {number} TIME_OUT 查找的次数 * @param {function(string): any} searchFunc 搜索函数 * @param {function(): void} [finalFunc] 出错后执行的函数 */ function WAIT_ELEMENT_LOADED(selector, func, TIME_OUT = 30, searchFunc = document.querySelector.bind(document), finalFunc = undefined) { WAIT_ELEMENT_LOADED_ASYNC(selector, TIME_OUT, searchFunc).then(func, function (error) { if (finalFunc) { finalFunc; } }); } /** * 等待文档渲染完成 * @param {function(): void} func */ function WAIT_DOCUMENT_READY(func, documentNode = document) { WAIT_ELEMENT_LOADED(`${documentNode.nodeName} readyState`, func, 10, function () { if (documentNode.readyState == "complete") { return true; } return undefined; }) } /** * 获取url参数 * @param {string} url * @param {string} attrName * @returns {string | undefined} 参数值 */ function GET_URL_ATTR(url, attrName) { return MATCH_REG(RegExp(`${attrName}=([^&]*)&?`, 'i'), url); } /** * 匹配正则表达式 * @param {RegExp} regExp * @param {string} str * @returns {string | undefined} 第一个符合需求的字符串 */ function MATCH_REG(regExp, str) { let regExpMatchArray = regExp.exec(str); if (regExpMatchArray && regExpMatchArray.length > 1) { return regExpMatchArray[1].trim(); } return undefined; } /** * @param {string} cssText */ function INSERT_CSS(cssText, documentNode = document) { let newStyleNode = documentNode.createElement('style'); try { newStyleNode.appendChild(documentNode.createTextNode(cssText)); } catch (e) { DEBUGLOG(e, STR_DEBUG_LABEL_ERROR); // @ts-ignore newStyleNode.rel = 'stylesheet'; // @ts-ignore newStyleNode.styleSheet.cssText = cssText; } let head = documentNode.getElementsByTagName('head')[0]; head.appendChild(newStyleNode); WAIT_DOCUMENT_READY(function () { }, documentNode) } class SettingDialog extends VoeocDialog { /** * 生成复选框 * @param {SettingValue} settingValue * @param {string} onchangeFunc * @param {string} cssText * @returns 返回生成的HTML */ static #generateHTMLCheckbox(settingValue, onchangeFunc = "", cssText = "") { let checked = settingValue.value ? "checked" : ""; return ``; } /** * 生成数字输入器 * @param {SettingValue} settingValue * @returns 返回生成的HTML */ static #generateHTMLNumberInputbox(settingValue) { let id_show_value = `${settingValue.key}-show`; let disabled = (settingValue.linkObj && settingValue.linkObj.defaultValueType == SettingValue.TYPE.BOOLEAN && !settingValue.linkObj.value); let min = undefined; let max = undefined; if (settingValue.range) { min = settingValue.range.min; max = settingValue.range.max; } return `
${settingValue.label}(${settingValue.value}):
`; } /** * * @param {SettingValue} settingValue */ static #getInputTrueValue(settingValue) { AUTO_CATCH_ERROR(() => { // @ts-ignore let newValue = document.getElementById(settingValue.key).value; settingValue.value = settingValue.parseFunc(newValue); }); } static OPEN_DIALOG() { // 重新刷新存储的配置,应用到窗口上 // for (let key in SETTINGS_DATA) { // let settingValue = SETTINGS_DATA[key]; // if (settingValue instanceof SettingValue) { // settingValue.loadValue(); // } // } // 重新显示窗口 SettingDialog.getInstance().open(); } /**@returns {SettingDialog} */ static getInstance() { throw new Error("请使用SingleInstanceManager初始化"); } constructor() { SingleInstanceManager.CHECK_SINGLE_INSTANCE(SettingDialog); super(); } /**@type {HTMLDivElement} */ #settingDialogNode; /**@type {HTMLDivElement} */ #settingDialogValueListNode; init(self = this) { // 生成对话框数据内容节点 self.#settingDialogValueListNode = document.createElement("div"); self.#settingDialogValueListNode.className = "voeoc-settting-value-list"; let settingvaluelisthtml = ""; for (let key in SETTINGS_DATA) { let settingValue = SETTINGS_DATA[key]; if (settingValue instanceof SettingValue) { AUTO_CATCH_ERROR(() => { /**@type {SettingValue} */ switch (settingValue.defaultValueType) { case SettingValue.TYPE.NUMBER: settingvaluelisthtml += SettingDialog.#generateHTMLNumberInputbox(settingValue); break; case SettingValue.TYPE.BOOLEAN: let onchangeFunc = "if((this.value=='checked')!=this.checked){this.value=this.checked?'checked':'';}else{return;}"; let linkFunc = ""; if (settingValue.linkObj) { linkFunc = `let linkNode = document.getElementById('${settingValue.linkObj.key}');` switch (settingValue.linkObj.defaultValueType) { case SettingValue.TYPE.BOOLEAN: if (settingValue.linkType == SettingValue.LINK_TYPE.OPPOSITE || settingValue.linkType == SettingValue.LINK_TYPE.SAME) { linkFunc += `linkNode.checked=${settingValue.linkType == SettingValue.LINK_TYPE.OPPOSITE ? "!" : ""}this.checked;linkNode.onchange();`; break; } case SettingValue.TYPE.NUMBER: linkFunc += "linkNode.disabled=!this.checked;if(this.checked){linkNode.parentNode.classList.remove('disabled')}else{linkNode.parentNode.classList.add('disabled')}"; break; case SettingValue.TYPE.STRING: default: throw new Error("不支持该类型"); break; } } onchangeFunc += linkFunc; if (settingValue.label instanceof SWITCHLABEL && settingValue.label.type == SWITCHLABEL.LABEL_TYLE.SWITCH) { onchangeFunc += `let stateList = [];let indexOn = this.checked ? 0 : 1;for(let i=0; i<2; ++i) {let stateLabel = document.getElementById('${settingValue.key}-'+i);if(i == indexOn) {stateLabel.classList.add('on');} else {stateLabel.classList.remove('on');}}`; settingvaluelisthtml += "
"; settingvaluelisthtml += SettingDialog.#generateHTMLCheckbox(settingValue, onchangeFunc, "visibility:hidden"); settingvaluelisthtml += ``; let indexOn = settingValue.value ? 0 : 1; for (let i = 0; i < 2; ++i) { settingvaluelisthtml += `
`; } settingvaluelisthtml += "
"; } else { settingvaluelisthtml += "
"; settingvaluelisthtml += SettingDialog.#generateHTMLCheckbox(settingValue, onchangeFunc); settingvaluelisthtml += ``; settingvaluelisthtml += "
"; } break; case SettingValue.TYPE.STRING: default: throw new Error("不支持该类型"); break; } }) } else if (typeof settingValue == "string") { settingvaluelisthtml += settingValue; } } self.#settingDialogValueListNode.innerHTML = settingvaluelisthtml.replace(/\n/g, ""); // 确认并刷新按钮 let settingDialogButtonConfirmAndReload = document.createElement("div"); settingDialogButtonConfirmAndReload.className = "voeoc-setting-button"; settingDialogButtonConfirmAndReload.style.cssText = "background: #4f42e6;"; settingDialogButtonConfirmAndReload.innerText = "保存并刷新"; // 确认按钮 let settingDialogButtonConfirm = document.createElement("div"); settingDialogButtonConfirm.className = "voeoc-setting-button"; settingDialogButtonConfirm.style.cssText = "background: #4289e6;"; settingDialogButtonConfirm.innerText = "保存"; // 取消按钮 let settingDialogButtonCancel = document.createElement("div"); settingDialogButtonCancel.className = "voeoc-setting-button"; settingDialogButtonCancel.innerText = "取消"; // 处理按钮面板 let settingDialogButtonPad = document.createElement("div"); settingDialogButtonPad.className = "voeoc-setting-button-pad"; settingDialogButtonPad.appendChild(settingDialogButtonConfirmAndReload); settingDialogButtonPad.appendChild(settingDialogButtonConfirm); settingDialogButtonPad.appendChild(settingDialogButtonCancel); // 事件处理 settingDialogButtonCancel.onclick = function () { self.close(); }; settingDialogButtonConfirm.onclick = function () { self.#getAllDialogValue(); self.close(); } settingDialogButtonConfirmAndReload.onclick = function () { self.#getAllDialogValue(); window.location.hash = ""; window.location.reload(); } let settingDialogContentNode = document.createElement("div"); settingDialogContentNode.className = "voeoc-settting-content"; settingDialogContentNode.appendChild(self.#settingDialogValueListNode); settingDialogContentNode.appendChild(settingDialogButtonPad); // 对话框节点 self.#settingDialogNode = document.createElement("div"); self.#settingDialogNode.className = "voeoc-setting-dialog"; self.#settingDialogNode.appendChild(settingDialogContentNode); document.body.appendChild(self.#settingDialogNode); super.setState(HISTORY_STATE.SETTINGDIALOG, self.#settingDialogNode); } #getAllDialogValue() { for (let key in SETTINGS_DATA) { if (SETTINGS_DATA[key] instanceof SettingValue) { if (SETTINGS_DATA[key].label instanceof Array) { // 含有多个label } SettingDialog.#getInputTrueValue(SETTINGS_DATA[key]); } } } } class CustomLzlExpandManager { // 实现楼中楼展开的逻辑管理器 static #STR_NEWOPENLZLTEXT = "展开评论"; // 打开楼中楼按钮的文本 static #STR_REMAINDOPENLZLTEXT = function (num) { return `剩余${num}个评论`; } static #LZL_CONTENT_TYPE = { // 楼中楼评论内容元素类型 TEXT: 0, // 文本 EMOJI: 2, // 表情 USERNAME: 4, // 用户名,一般用作回复 }; #enable; // 按钮开关 #currentPageNum; // 当前展开页,用于网络请求 #pageSize; // 单个页面评论数量,至少为10 #originItemNodeList; // 原始楼中楼评论显示节点 #sampleItemNode; // 原始楼中楼评论样本 #lzTagHTML; // 一个楼主方框标记 #data_v_a; // 评论中的第一个dataset数据(data-v-***),用于还原样式 #data_v_b; // 评论中的第二个dataset数据(data-v-***),用于还原样式 #pid; // 楼层id #floorNum; // 当前楼层的楼层数 #expandNum; // 记录当前展开的次数 get floorNum() { return this.#floorNum; } // 存储的网页节点 #floorNode; // 当前楼层 #lzlNode; // 楼中楼 #expandBtnNode; // 楼中楼展开按钮 #expandBtnTextNode; // 楼中楼展开按钮的文本 #expandTimeoutTimer; // 超时处理器 /** * * @param {HTMLElement} floorNode * @param {HTMLElement} expandBtnNode * @param {string} floorNum * @param {string} pid */ constructor(floorNode, expandBtnNode, floorNum, pid) { ASSERT(floorNode, "floorNode is null"); ASSERT(expandBtnNode, "expandBtnNode is null"); let self = this; self.#enable = true; self.#currentPageNum = 1; self.#pageSize = SETTINGS_DATA.eachExpandSize.value < 10 ? 10 : SETTINGS_DATA.eachExpandSize.value; // 单个展开的页面评论数量,至少为10 self.#floorNode = floorNode; // 当前楼层节点 self.#expandBtnNode = expandBtnNode; // 楼中楼展开按钮 self.#lzlNode = self.#floorNode.querySelector("div.lzl-post"); self.#originItemNodeList = self.#lzlNode.getElementsByClassName("lzl-post-item"); self.#sampleItemNode = self.#originItemNodeList[0].cloneNode(true); self.#floorNum = floorNum; self.#pid = pid; self.#expandNum = 0; // 读取复制data-v let dvlist = []; for (let dv in self.#originItemNodeList[0].querySelector(".thread-text").dataset) { dvlist.push(`data-v-${dv.slice(1).replace('-', '')}`); } self.#data_v_a = "data-v-aeeee"; self.#data_v_b = "data-v-beeee"; AUTO_CATCH_ERROR(() => { self.#data_v_a = dvlist[0]; }); AUTO_CATCH_ERROR(() => { self.#data_v_b = dvlist[1]; }); self.#lzTagHTML = ``; // 创建新按钮节点 self.#expandBtnTextNode = document.createElement("span"); self.#expandBtnTextNode.className = "open-app-text-real"; self.#expandBtnTextNode.innerHTML = CustomLzlExpandManager.#STR_NEWOPENLZLTEXT; // 绑定长按事件 let clickFunc = SETTINGS_DATA.isClickToExpandLzlPage.value ? self.expandLzl.bind(self) : self.openLzlPage.bind(self); let longclickFunc = SETTINGS_DATA.isLongClickToExpandLzlPage.value ? self.expandLzl.bind(self) : self.openLzlPage.bind(self); let timeOutEvent = 0; const TIME_OUT = 500; self.#expandBtnNode.ontouchstart = function () { timeOutEvent = setTimeout(function () { timeOutEvent = 0; // 执行长按 longclickFunc(); }, TIME_OUT); return false; } self.#expandBtnNode.ontouchend = function () { clearTimeout(timeOutEvent); if (timeOutEvent != 0) { // 判断为单击 clickFunc(); } return false; } self.#expandBtnNode.ontouchmove = function () { clearTimeout(timeOutEvent); timeOutEvent = 0; } if (IS_DEBUG) { // 同步鼠标事件,在PC端进行测试 self.#expandBtnNode.onmousedown = self.#expandBtnNode.ontouchstart; self.#expandBtnNode.onmouseup = self.#expandBtnNode.ontouchend; self.#expandBtnNode.onmousemove = self.#expandBtnNode.ontouchmove; } // 替换新按钮 self.#expandBtnNode.insertBefore(self.#expandBtnTextNode, self.#expandBtnNode.children[0]); // 自动展开剩余评论 if (SETTINGS_DATA.isAutoExpand.value) { DEBUGLOG(self.#floorNum, "AutoExpand"); self.expandLzl(); } } /** * 原地展开楼中楼评论 * @param {Boolean} isTheLast 是否为最后一次展开,避免无限递归 * @param {function(any):void} [waitFinishFunc] 等待加载完毕,如果发生错误,则会传入相应的错误状态 * @param {this} self * */ expandLzl(isTheLast = false, waitFinishFunc = undefined, self = this) { ASSERT(self.#enable, `尝试展开不存在的评论区,楼层号${self.#floorNum}`); DEBUGLOG(self.#floorNum, "expandLzl"); let mainPage = MainPage.getInstance(); let someKey = mainPage.someKey; let url = `${window.origin}/mg/o/getFloorData?pn=${self.#currentPageNum}&rn=${self.#pageSize}&tid=${someKey.tid}&pid=${self.#pid}`; DEBUGLOG(url, "expandLzl"); let abortFunc = GM.xmlhttpRequest({ method: "get", url: url, onload: function (details) { self.#endExpandAnimation(); // 爬取解析楼中楼评论数据 let floorData = undefined; let subpostlist = undefined; try { floorData = JSON.parse(details.responseText); subpostlist = floorData.data.sub_post_list; // 评论列表 if (!subpostlist || subpostlist.length == 0) { throw ("sub_post_list为空"); } } catch (e) { // 无法获取楼中楼数据 DEBUGLOG(`无法获取楼中楼数据,url:${url}\n错误:${e}`, STR_DEBUG_LABEL_ERROR); self.#showError(true); return; } // 复原颜色 self.#showError(false); // 去掉前两个评论 if (self.#currentPageNum == 1) { AUTO_CATCH_ERROR(() => { for (let i = self.#originItemNodeList.length - 1; i > -1; i--) { self.#lzlNode.removeChild(self.#originItemNodeList[i]); } }); } subpostlist.forEach(function (subpost) { // 遍历每一行评论 let contentHTML = ""; // 单行评论的HTML subpost.content.forEach(function (subContent) { // 遍历单行评论的每一个元素 let itemHTML = ""; // 元素的HTML switch (subContent.type) { case CustomLzlExpandManager.#LZL_CONTENT_TYPE.EMOJI: itemHTML = `${subContent.text}`; break; case CustomLzlExpandManager.#LZL_CONTENT_TYPE.USERNAME: if (subContent.uid == mainPage.lzId) { itemHTML = ` ${subContent.text} ${self.#lzTagHTML} `; } else { itemHTML = ` ${subContent.text} `; } break; case CustomLzlExpandManager.#LZL_CONTENT_TYPE.TEXT: default: // 如有其他的类型暂时用文本代替 itemHTML = `${subContent.text}`; break; } contentHTML += itemHTML; }) let newItemNode = self.#sampleItemNode.cloneNode(true); // 新的评论行 newItemNode.querySelector(".username").innerHTML = `${subpost.author.show_nickname} ${(mainPage.lzId == subpost.author.id) ? self.#lzTagHTML : ""}:`; newItemNode.querySelector(".thread-text").innerHTML = contentHTML; self.#lzlNode.insertBefore(newItemNode, self.#expandBtnNode); }); // 展开结束后处理剩余评论 let pageinfo = floorData.data.page; // 楼中楼信息,包括楼层数、页面大小、页面数量 let total_page = parseInt(pageinfo.total_page); // 总页数 if (total_page > self.#currentPageNum) { // 仍有剩余评论未展开 self.#currentPageNum++; let total_num = parseInt(pageinfo.total_num); let remaind_num = total_num - self.#pageSize * (self.#currentPageNum - 1); self.#expandBtnNode.children[0].innerHTML = CustomLzlExpandManager.#STR_REMAINDOPENLZLTEXT(remaind_num); if (SETTINGS_DATA.isRemaindAutoExpand.value && SETTINGS_DATA.remaindAutoExpandSize.value > remaind_num) { // 当剩余评论过少时自动展开 if (!isTheLast) { // 检查当前是否强制设置为为最后一次展开(避免楼中楼更新时无限递归) self.expandLzl(true); } } else if (SETTINGS_DATA.isAutoExpand.value && self.#expandNum < SETTINGS_DATA.autoExpandNum.value) { // 自动展开剩余评论 self.expandLzl(); } } else { // 所有评论已展开时隐藏展开按钮 self.#destroy.apply(self); } // 成功展开后记录次数 self.#expandNum++; }, onerror: function (details) { self.#endExpandAnimation(); self.#showError(true); DEBUGLOG(`无法加载评论区,爬取的url为${details.responseURL}`, STR_DEBUG_LABEL_ERROR); }, onabort: onerror, ontimeout: onerror, }); // 动画处理 self.#startExpandAnimation(abortFunc); } // 另一种打开楼中楼的方法,将页面加载到iframe弹框里 openLzlPage(self = this) { let someKey = MainPage.getInstance().someKey; if (!someKey.tid || !someKey.postAuthorId || !someKey.forumId) { self.#showError(); return; } let newHash = `#/lzlPage?tid=${someKey.tid}&pid=${self.#pid}&floor=${self.#floorNum}&postAuthorId=${someKey.postAuthorId}&forumId=${someKey.forumId}`; DEBUGLOG(newHash, "openLzlPage hash"); LzlPage.getInstance().showAndReload(newHash); } #showError(isError = true, self = this) { if (isError) { self.#expandBtnTextNode.classList.add("error"); } else { self.#expandBtnTextNode.classList.remove("error"); } } // 开始动画 #startExpandAnimation(abortFunc, self = this) { self.#expandBtnNode.disabled = true; self.#expandBtnNode.classList.add("loading"); self.#expandTimeoutTimer = setTimeout(function () { self.#endExpandAnimation(); self.#showError(true); DEBUGLOG(`加载异常,并且超时未处理`, STR_DEBUG_LABEL_ERROR); abortFunc(); }, 5000); } // 结束动画 #endExpandAnimation(self = this) { self.#expandBtnNode.disabled = false; self.#expandBtnNode.classList.remove("loading"); if (self.#expandTimeoutTimer) { clearTimeout(self.#expandTimeoutTimer); self.#expandTimeoutTimer = undefined; } } #destroy(self = this) { try { self.#enable = false; self.#expandBtnNode.style.display = "none"; self.#expandBtnNode.parentNode.removeChild(self.#expandBtnNode); } finally { //this = null; } } } /** * 栈 * @template T * */ class Stack { /**@type {Array} */ #items; constructor() { this.#items = []; } /** * @param {T} element */ push(element) { this.#items.push(element); }; pop() { return this.#items.pop(); }; peek() { return this.#items[this.#items.length - 1]; }; isEmpty() { return this.#items.length == 0; }; size() { return this.#items.length; }; clear() { this.#items = []; }; } /** * 队列 * @template T * */ class Queue { /**@type {Array} */ #queue; constructor() { this.#queue = []; } /**@param {T} element*/ enqueue(element) { this.#queue.push(element); }; dequeue() { return this.#queue.shift(); }; /** @param {number} index*/ at(index) { return this.#queue[index]; } get front() { return this.#queue[0]; }; get isEmpty() { return this.#queue.length === 0; }; get length() { return this.#queue.length; }; } // 楼中楼弹框的缓存管理 class LzlPageCacheManager { #srcQueue; // 网址队列 #iframeList; // 对应网址的iframe对象集合 constructor() { this.#srcQueue = new Queue(); this.#iframeList = new Map(); } appendOrReplace(src, lzlIframeNode) { if (this.#iframeList.has(src)) { // 缓存里找到并替换原有页面 this.#removeOldNode(src); } else { // 缓存里找不到对应页面 this.#srcQueue.enqueue(src); if (this.#srcQueue.length > SETTINGS_DATA.lzlCacheSize.value) { // 页面数量过大 let oldSrc = this.#srcQueue.dequeue(); this.#removeOldNode(oldSrc); } } this.#iframeList.set(src, lzlIframeNode); DEBUGLOG(`${src}\n${lzlIframeNode.innerHTML}`, "appendOrReplace"); } get(src) { if (this.#iframeList.has(src)) { return this.#iframeList.get(src); } else { return undefined; } } #removeOldNode(oldSrc) { let oldIframe = this.#iframeList.get(oldSrc); DEBUGLOG(oldSrc, "remove"); oldIframe.parentNode.removeChild(oldIframe); this.#iframeList.delete(oldSrc); oldIframe = undefined; } } // 下滑触发隐藏的控制器 class LzlPageSlideDownController { static #DISTANCE_PX_SHOW_SLIDE_BTN = CM2PX(5); // 显示下拉按钮的滑动距离 static #DISTANCE_PX_CONFIRM_SLIDE_DOWN = CM2PX(10); // 滑动距离大于此值,则隐藏窗口,单位为像素 /**@type {Window} */ #iframeWindow; /**@type {HTMLIFrameElement} */ #iframeNode; #startTouchY = NaN; // (当数字与NaN比较时,始终为false) #onslidedownendFunc; #slideDownBtnNode; // 下拉按钮 get slideDownBtnNode() { return this.#slideDownBtnNode; } constructor(onslidedownendFunc = undefined) { let self = this; self.#onslidedownendFunc = onslidedownendFunc; // 下拉按钮 self.#slideDownBtnNode = document.createElement("div"); self.#slideDownBtnNode.className = "lzl-nav-btn slide-down-btn"; self.#slideDownBtnNode.innerHTML = HTML_SVG_DOWN_BTN; } /** * * @param {HTMLIFrameElement} iframeNode */ setIframe(iframeNode, self = this) { self.#iframeNode = iframeNode; // @ts-ignore self.#iframeWindow = iframeNode.contentWindow; self.#iframeWindow.document.addEventListener("touchstart", self.#touchstart.bind(self), false); self.#iframeWindow.document.addEventListener("touchmove", self.#touchmove.bind(self), false); self.#iframeWindow.document.addEventListener("touchend", self.#touchend.bind(self), false); // self.#iframeWindow.document.ontouchstart = self.#touchstart.bind(self); // self.#iframeWindow.document.ontouchmove = self.#touchmove.bind(self); // self.#iframeWindow.document.ontouchend = self.#touchend.bind(self); } #triggerSlideAbort(self = this) { // 整个滑动动作结束后的收尾工作 self.#startTouchY = NaN; self.#slideDownBtnNode.style.marginTop = 0; self.#slideDownBtnNode.style.opacity = 0; self.#slideDownBtnNode.classList.remove("confirm"); } /** @param {TouchEvent} event*/ #touchstart(event, self = this) { DEBUGLOG(self.#iframeWindow.scrollY, "touchstart"); self.#iframeNode.focus(); self.#iframeWindow.focus(); if (self.#iframeWindow.scrollY < 5) { // 在顶部继续下滑 self.#startTouchY = event.changedTouches[0].screenY; } return STOPPROPAGATION(event); } /** @param {TouchEvent} event*/ #touchmove(event, self = this) { event.preventDefault(); let touchY = event.changedTouches[0].screenY; DEBUGLOG(touchY, "touchmove"); if (!self.#startTouchY && self.#iframeWindow.scrollY < 5) { // 开始在顶部继续下滑 self.#startTouchY = touchY; } if (self.#startTouchY) { // 在顶部继续下滑 let distance = touchY - self.#startTouchY; // 滑动距离 DEBUGLOG(distance, "distance") if (distance > LzlPageSlideDownController.#DISTANCE_PX_SHOW_SLIDE_BTN) { // 显示按钮 self.#slideDownBtnNode.style.opacity = 1; if (distance > LzlPageSlideDownController.#DISTANCE_PX_CONFIRM_SLIDE_DOWN) { // 抵达预定位置,切换按钮确认状态 self.#slideDownBtnNode.classList.add("confirm"); self.#slideDownBtnNode.style.marginTop = `${LzlPageSlideDownController.#DISTANCE_PX_CONFIRM_SLIDE_DOWN}px`; } else { // 未达确认状态 self.#slideDownBtnNode.classList.remove("confirm"); self.#slideDownBtnNode.style.marginTop = `${distance}px`; } } else { // 逐渐显示按钮 let opacity = distance / LzlPageSlideDownController.#DISTANCE_PX_SHOW_SLIDE_BTN; self.#slideDownBtnNode.style.opacity = opacity; } } else { // 正常滚动 } return STOPPROPAGATION(event); } /** @param {TouchEvent} event*/ #touchend(event, self = this) { try { DEBUGLOG("touchend", "touchend"); if (self.#slideDownBtnNode.classList.contains("confirm")) { // 确认下滑,执行隐藏命令 self.#onslidedownendFunc(); } else { // 没有确认,恢复按钮状态,并且弹框不执行任何动作 } } finally { self.#triggerSlideAbort(); return STOPPROPAGATION(event); } } } class LzlPage extends VoeocDialog { /**@type {LzlPageCacheManager} */ #lzlPageCacheManager; /**@type {LzlPageSlideDownController} */ #lzlPageSlideDownController; // HTML节点 #lzlPageNode; /**@type {HTMLIFrameElement | undefined} */ #lzlPageIframeNode; #lzlPageBackgroundNode; #reloadBtnNode; // 刷新按钮 #closeBtnNode; // 关闭按钮 /**@returns {LzlPage} */ static getInstance() { throw new Error("请使用SingleInstanceManager初始化"); } constructor() { SingleInstanceManager.CHECK_SINGLE_INSTANCE(LzlPage); super(); } init(self = this) { ASSERT(!document.getElementById(STR_ID_LZLPAGE), "已存在楼中楼弹框id,请检查代码"); self.#lzlPageCacheManager = new LzlPageCacheManager(); self.#lzlPageSlideDownController = new LzlPageSlideDownController(this.close.bind(this)); // 楼中楼展示页 self.#lzlPageNode = document.createElement("div"); self.#lzlPageNode.id = STR_ID_LZLPAGE; self.#lzlPageNode.ontouchmove = STOPPROPAGATION; self.#lzlPageNode.onscroll = STOPPROPAGATION; super.setState(HISTORY_STATE.LZLPAGE, self.#lzlPageNode); // 显隐动画结束事件 self.#lzlPageNode.addEventListener("transitionend", function () { if (self.#lzlPageNode.classList.contains("show")) { // 显示完毕 } else { // 隐藏完毕 } }, false); // 刷新按钮 self.#reloadBtnNode = document.createElement("div"); self.#reloadBtnNode.className = "lzl-nav-btn lzl-reload-btn"; self.#reloadBtnNode.innerHTML = HTML_SVG_RELOAD_BTN; self.#reloadBtnNode.reloadTimeOut = undefined; self.#reloadBtnNode.startLoading = function () { self.#lzlPageNode.classList.add("loading"); clearTimeout(self.#reloadBtnNode.reloadTimeOut); self.#reloadBtnNode.reloadTimeOut = setTimeout(function () { self.#lzlPageNode.classList.remove("loading"); self.#lzlPageNode.classList.add("error"); }, 3000) } self.#reloadBtnNode.finishLoading = function () { self.#lzlPageNode.classList.remove("loading"); clearTimeout(self.#reloadBtnNode.reloadTimeOut); self.#reloadBtnNode.reloadTimeOut = undefined; self.#lzlPageNode.classList.remove("error"); } // 关闭按钮 self.#closeBtnNode = document.createElement("div"); self.#closeBtnNode.className = "lzl-nav-btn"; self.#closeBtnNode.style.cssText = "margin-right:.1rem;"; self.#closeBtnNode.innerHTML = HTML_SVG_CLOSE_BTN; // 用于加载实际页面的iframe self.#lzlPageIframeNode = undefined; // 背景板 self.#lzlPageBackgroundNode = document.createElement("div"); self.#lzlPageBackgroundNode.id = STR_ID_LZLPAGEBACKGROUND; // 用于拦截滚动链 let lzlPageScrollContentNode = document.createElement("div"); lzlPageScrollContentNode.style.cssText = "height:101%;"; // 点击事件 function close() { self.close(); } self.#lzlPageBackgroundNode.onclick = close; lzlPageScrollContentNode.onclick = close; self.#closeBtnNode.onclick = close; self.#reloadBtnNode.onclick = function () { self.reload(); }; // 添加元素到页面 self.#lzlPageNode.appendChild(self.#lzlPageBackgroundNode); self.#lzlPageNode.appendChild(lzlPageScrollContentNode); self.#lzlPageNode.appendChild(self.#reloadBtnNode); self.#lzlPageNode.appendChild(self.#lzlPageSlideDownController.slideDownBtnNode); document.body.insertBefore(self.#lzlPageNode, document.body.children[0]); } static generateNewLzlPageIframeNode(src = "") { // 楼中楼iframe加载器 let newLzlPageIframeNode = document.createElement("iframe"); newLzlPageIframeNode.className = `${STR_CLASSNAME_LZLPAGEIFRAME}`; newLzlPageIframeNode.setAttribute("src", src); newLzlPageIframeNode.setAttribute("frameborder", "0"); return newLzlPageIframeNode; } #lastLoadHash; #reloadNum; async reload(hash, isBanReload = false, self = this) { if (isBanReload) { // 禁止重复加载同一页面 try { if (self.#lzlPageIframeNode?.contentWindow?.location.hash == hash) { return; } } catch (e) { DEBUGLOG(e, STR_DEBUG_LABEL_ERROR); } } // 递归(刷新)次数限制 // if (self.#reloadNum > 2) { // return; // } // 检查重复 if (!hash) { hash = self.#lastLoadHash; self.#reloadNum++; } else { if (hash == self.#lastLoadHash) { // 传入的hash重复了 self.#reloadNum++; } else { self.#reloadNum = 0; self.#lastLoadHash = hash; } } // 生成新的页面 let newLzlPageIframeNode = LzlPage.generateNewLzlPageIframeNode(hash); self.#lzlPageCacheManager.appendOrReplace(hash, newLzlPageIframeNode); self.#lzlPageIframeNode = newLzlPageIframeNode; self.#lzlPageNode.insertBefore(self.#lzlPageIframeNode, self.#lzlPageBackgroundNode); if (self.#reloadNum > 0) { // 原地刷新 self.#lzlPageIframeNode.classList.add("show"); } else { setTimeout(() => { self.#lzlPageIframeNode?.classList.add("show"); }, 0); } // 获取载入结果 self.#reloadBtnNode.startLoading(); let navbar = undefined; try { navbar = await WAIT_ELEMENT_LOADED_ASYNC(".nav-bar-top", 5, function (selector) { // 搜索函数 if (!self.#lzlPageIframeNode || !self.#lzlPageIframeNode.contentDocument) { return undefined; } return self.#lzlPageIframeNode.contentDocument.querySelector(selector); }); } catch (e) { // 查找失败,说明没有加载成功 DEBUGLOG(e, STR_DEBUG_LABEL_ERROR); //self.reload(); // 递归刷新 return; } // 处理载入 let iframeWindow = self.#lzlPageIframeNode.contentWindow; try { if (!iframeWindow) { throw new Error(`楼中楼加载错误。iframeWindow=null`); } // 拦截滚动链 /**@type {HTMLElement} */(iframeWindow.document.getElementsByTagName("HTML")[0]).style.overscrollBehavior = "contain"; // 下滑功能 WAIT_DOCUMENT_READY(function () { if (self.#lzlPageIframeNode) { // 添加下滑隐藏功能 self.#lzlPageSlideDownController.setIframe(self.#lzlPageIframeNode); // 获取焦点 self.#lzlPageIframeNode.focus(); // 尝试屏蔽上层滚动 self.#lzlPageIframeNode.contentWindow?.onscroll ?? STOPPROPAGATION; } }, iframeWindow.document) // 隐藏多余按钮 INSERT_CSS(STR_CSS_REMOVE, iframeWindow.document); let backBtnNode = navbar.querySelector(".logo-wrapper"); let openAppBtnNode = navbar.querySelector(".more-btn-desc"); backBtnNode.disabled = true; backBtnNode.style.visibility = "hidden"; openAppBtnNode.style.display = "none"; // 添加新的关闭按钮 let newCloseBtnNode = self.#closeBtnNode.cloneNode(true); newCloseBtnNode.onclick = self.#closeBtnNode.onclick; navbar.replaceChild(newCloseBtnNode, openAppBtnNode); } catch (e) { DEBUGLOG(e, STR_DEBUG_LABEL_ERROR); } finally { // 结束动画。若无法执行到此处,则会自动超时显示变成错误状态 self.#reloadBtnNode.finishLoading(); } } showAndReload(hash, self = this) { if (self.#lzlPageIframeNode) { // 隐藏前一页面 self.#lzlPageIframeNode.classList.remove("show"); } self.#lzlPageIframeNode = self.#lzlPageCacheManager.get(hash); if (self.#lzlPageIframeNode) { // 成功加载了缓存的内容 DEBUGLOG(`显示缓存页${hash}`, "cache"); self.#lzlPageIframeNode.classList.add("show"); } else { // 没有缓存,直接刷新 DEBUGLOG(`加载新页面${hash}`, "cache"); self.reload(hash, true); } // 设置评论页为显示 self.open(); } } class MainPage { /**@type {HTMLElement | null} 一楼*/ tieNode; /**@type {HTMLElement | null} 吧名*/ tiebaNameNode; lzId = ""; // 楼主id currentHash = window.location.hash; // 存储当前页的Hash,页面变动的依据 /**@type {number} 存储当前滚动位置 */ currentScrollYPos; floorDataList = {} // 存储所有楼层数据以便搜索,索引为楼层数字符串,值为抓取的json。主要信息为pid,获取路径floorDataList[floor].id someKey = { // 一些用于网络请求的关键字,页面加载或变动时自自动更新 host: "tieba.baidu.com", tid: "", postAuthorId: "", forumId: "", } /**@returns {MainPage} */ static getInstance() { throw new Error("请使用SingleInstanceManager初始化"); } constructor() { SingleInstanceManager.CHECK_SINGLE_INSTANCE(MainPage); } init(self = this) { AUTO_CATCH_ERROR(() => { // 监听楼层加载的网络事件 // @ts-ignore let oldXHR = GM.unsafeWindow.XMLHttpRequest; GM.unsafeWindow.XMLHttpRequest = function () { let realXHR = new oldXHR(); realXHR.addEventListener('readystatechange', function () { DEBUGLOG(realXHR.responseURL, "realXHR.responseURL"); if (VOEOC_REG.PBDATA.test(realXHR.responseURL) && realXHR.response != "") { self.parsePbData(realXHR.response, realXHR.responseURL); } }, false); return realXHR; } // 通过监听页面滚动变化获取页面变动情况 GM.unsafeWindow.onscroll = function () { self.checkUrlHashChange(); if (getPageType() == PAGE_TYPE.POSTPAGE) { // 只会记录评论页的滚动位置 if (window.pageYOffset != 0) { // 记录当前滚动位置 self.currentScrollYPos = window.pageYOffset; //DEBUGLOG(scrollPos, "scrollPos"); } } } // 页面变动时触发检查 GM.unsafeWindow.onhashchange = function (e) { self.checkUrlHashChange(); } }); REGISTER_MENUCOMMAND(); // 初始化楼中楼弹出框 LzlPage.getInstance().init(); // 初始化设置窗口弹框 SettingDialog.getInstance().init(); // 初始化弹窗管理器 HistoryStateManager.getInstance().init(); WAIT_ELEMENT_LOADED("div.nav-bar-v2-fixed:nth-child(1)", (navbarfixed) => { self.tiebaNameNode = document.querySelector(".forum-block"); // 获取吧名 self.tieNode = document.querySelector(".main-thread-content"); // 获取楼主发帖内容 /**@type {HTMLElement | null} */ let postbtn = document.querySelector(".post-page-entry-btn"); // 展开评论页的按钮 // 点击展开按钮 if (postbtn) { postbtn.click(); // 手动触发页面刷新检测 self.checkUrlHashChange(); } else { // 展开按钮不存在,可能是楼层太少了 DEBUGLOG(postbtn, "postbtn"); // 页面开始加载时监听会大概率失效,所以这里只能主动触发抓取楼层信息的请求,发起后交给监听程序 let pageId = MATCH_REG(RegExp(`${window.origin}/p/([0-9]*)?#?/?`, 'i'), window.location.href); let url = `${window.origin}/mg/p/getPbData?kz=${pageId}&obj_param2=firefox&format=json&eqid=&refer=&pn=1&rn=5`; DEBUGLOG(url, "url"); GM.xmlhttpRequest({ method: "get", url: url, onload: function (details) { DEBUGLOG(details.responseText, "xmlhttpRequest onload"); // 收集数据 self.parsePbData(details.responseText, url); // 使用获取的新数据,强制手动修改页面 self.checkUrlHashChange(true); }, onerror: function (details) { DEBUGLOG(`获取主页数据失败,url:${details.responseURL}`, STR_DEBUG_LABEL_ERROR); }, }); } }) } /** * 开始监听楼层变化 * @param {HTMLElement} floorParentNode */ listenFloorChange(floorParentNode, self = this) { /** * 当有新楼层加载时调用 * @param {HTMLElement} floorNode */ function onNewFloorAdded(floorNode) { if (floorNode.classList.contains(STR_VOEOCMARK)) { // 已被打上标记 return; } floorNode.classList.add(STR_VOEOCMARK); // 手动标记,避免重复操作 AUTO_CATCH_ERROR(() => { /**@type {HTMLElement | null} */ let expandBtnNode = floorNode.querySelector(".open-app-guide"); // 楼中楼展开按钮 if (expandBtnNode) { let floorinfoNode = floorNode.querySelector(".floor-info"); // 楼层数元素 if (!floorinfoNode) { throw new Error("楼层数节点不存在"); } let floorNum = MATCH_REG(RegExp(`第([0-9]+)楼`, 'i'), floorinfoNode.innerHTML); // 楼层数 if (!floorNum) { throw new Error("无法获取楼层数"); } let floorPid = self.floorDataList[floorNum].id; // 楼层id let newCustomLzlExpandManager = new CustomLzlExpandManager(floorNode, expandBtnNode, floorNum, floorPid); } }); }; // 使用新按钮刷新楼层 function searchAndUpdatePostPage() { // 遍历所有新加的楼层元素 /**@type {NodeListOf} */ let floorNodeList = floorParentNode.querySelectorAll(`div.post-item:not(.${STR_VOEOCMARK})`); floorNodeList.forEach(onNewFloorAdded); } // 注册楼层元素添加事件 let observer = new MutationObserver(function (_mutationList) { searchAndUpdatePostPage(); }); observer.observe(floorParentNode, { attributes: false, childList: true, characterData: false, subtree: false, }); searchAndUpdatePostPage(); } /** * 检测页面Hash变化,变化时将根据类型执行页面修改 * @param {boolean} force 为true时,无论检测到Hash是否变化均执行后续任务 * @param {this} self * @returns */ checkUrlHashChange(force = false, self = this) { if (self.currentHash != window.location.hash) { self.currentHash = window.location.hash; } else if (!force) { // 不强制执行修改的话,在hash没变时将直接退出函数 return false; } // 添加设置按钮 WAIT_ELEMENT_LOADED(".nav-bar-top", (navbar) => { AUTO_CATCH_ERROR(() => { const id = "VOEOC-ID-SETTINGBTNNODE"; if (document.getElementById(id)) { return; } let settingBtnNode = document.createElement("div"); settingBtnNode.id = id; settingBtnNode.className = "lzl-nav-btn"; settingBtnNode.style.cssText = "position:fixed;margin: 0.1rem;"; settingBtnNode.innerHTML = HTML_SVG_SETTING_BTN; settingBtnNode.onclick = SettingDialog.OPEN_DIALOG; navbar.appendChild(settingBtnNode); }); }) let pageType = getPageType(); if (pageType == PAGE_TYPE.POSTPAGE) { // 页面变动为评论页 // 收集url数据 let tid = GET_URL_ATTR(self.currentHash, "tid"); let postAuthorId = GET_URL_ATTR(self.currentHash, "postAuthorId"); let forumId = GET_URL_ATTR(self.currentHash, "forumId"); self.someKey.host = window.location.hostname; if (tid) { self.someKey.tid = tid; } if (postAuthorId) { self.someKey.postAuthorId = postAuthorId; } if (forumId) { self.someKey.forumId = forumId; } // 当页面变动时,刷新展开楼层的按钮 WAIT_ELEMENT_LOADED(".post-page-list", (postpagelist) => { // 等待页面加载完成 self.listenFloorChange(postpagelist); }, 10); // 恢复一楼显示 self.restorePostPage(); // 页面切换后恢复滚动位置 MainPage.scrollTo(self.currentScrollYPos); } else if (pageType == PAGE_TYPE.MAINPAGE) { // 页面变动为主页 if (self.tieNode) { // 当页面变动时,刷新展开楼层的按钮 WAIT_ELEMENT_LOADED(".pb-page-wrapper", (pbpageNode) => { // 等待页面加载完成 self.listenFloorChange(pbpageNode); }, 10); // 将剪切走的一楼复制回来 WAIT_ELEMENT_LOADED("#replySwitch", (splitlineNode) => { // 等待页面加载完成 AUTO_CATCH_ERROR(() => { if (!self.tieNode) { throw new Error("1楼不存在"); } if (!splitlineNode.parentNode) { throw new Error("复原1楼时页面加载失败"); } splitlineNode.parentNode.insertBefore(self.tieNode, splitlineNode); }); }, 10); MainPage.scrollTo(0); } } return true; } // 滚动到指定y坐标(页面刷新后,如果当前楼层数比较大,只能滚动到贴末尾的最大加载位置) static scrollTo(yPos, documentNode = document) { WAIT_ELEMENT_LOADED(".post-page", (_) => { // 等待页面加载完成 documentNode.documentElement.scrollTop = yPos; // 在一定时间内维持滚动位置 setTimeout(function () { documentNode.documentElement.scrollTop = yPos; DEBUGLOG(yPos, "scrollTo"); }, 200); }); } // 修改评论页,加入缺失的1楼和贴吧名等 restorePostPage(self = this) { DEBUGLOG("restore") WAIT_ELEMENT_LOADED(".text", (titletextNode) => { // 等待标题位置加载 // 显示贴吧名 AUTO_CATCH_ERROR(() => { if (!self.tiebaNameNode || !titletextNode.parentNode) { throw new Error("贴吧名无法加载"); } /**@type {HTMLElement} */ // @ts-ignore let tiebaNameCloneNode = self.tiebaNameNode.cloneNode(true); titletextNode.parentNode.replaceChild(tiebaNameCloneNode, titletextNode); // 关联点击贴吧名的事件 tiebaNameCloneNode.onclick = self.tiebaNameNode.click.bind(self.tiebaNameNode); }); // 显示楼主发帖层 AUTO_CATCH_ERROR(() => { if (!self.tieNode) { throw new Error("1楼内容为空!"); } // 复原样式丢失 self.tieNode.style.cssText = `margin-left:0.12rem;margin-right:0.12rem;margin-bottom:0.25rem;` /** @type {HTMLElement | null} 尝试找回楼主丢失的头像*/ let lzavatarNode = self.tieNode.querySelector(".avatar"); if (lzavatarNode) { lzavatarNode.style.backgroundImage = `url("${lzavatarNode.getAttribute("data-src")}")` } else { DEBUGLOG("找不到楼主头像", STR_DEBUG_LABEL_ERROR); } /** @type {HTMLElement | null} 尝试复原一楼的文字内容的字体样式*/ let textContentNode = self.tieNode.querySelector(".thread-text"); if (textContentNode) { textContentNode.style.cssText = `margin-top:0.18rem;font-size:0.16rem;line-height:0.28rem;` } else { DEBUGLOG("1楼不存在", STR_DEBUG_LABEL_ERROR); } /** @type {HTMLElement | null} 标题下方的分割,用于插入一楼*/ let replySwitchNode = document.querySelector("#replySwitch"); // if (replySwitchNode && replySwitchNode.parentNode) { replySwitchNode.parentNode.insertBefore(self.tieNode, replySwitchNode); } else { DEBUGLOG("1楼插入失败", STR_DEBUG_LABEL_ERROR); } }); // 尝试复原标题样式 AUTO_CATCH_ERROR(() => { /**@type {HTMLElement | null} */ let threadtitleNode = document.querySelector(".thread-title"); let isTopTitle = true; if (!threadtitleNode) { threadtitleNode = document.querySelector(".bottom-thread-title"); isTopTitle = false; } if (!threadtitleNode) { throw new Error("标题为空!"); } threadtitleNode.style.cssText = `margin-bottom: 0.13rem;font-size:0.22rem;font-weight:700;line-height:0.33rem;` /**@todo 置顶标题显示 */ if (false && isTopTitle) { // let threadtitleCloneNode = threadtitleNode.cloneNode(true) // threadtitleNode.style.visibility = "hidden"; // threadtitleCloneNode.style.cssText += `position: fixed !important;z-index: 99 !important;opacity: 0.8 !important;background-color: #FFFFFF !important;` // threadtitleNode.parentNode.insertBefore(threadtitleCloneNode, threadtitleNode) } }); }) } // 解析传过来的PBDATA的json /** * @param {any} responseText * @param {string} responseURL */ parsePbData(responseText, responseURL, self = this) { AUTO_CATCH_ERROR(() => { let data = undefined; if (typeof responseText == "object") { data = responseText.data; } else { data = JSON.parse(responseText).data; } ASSERT(data, "无法获取PBDATA"); // 获取内部参数 AUTO_CATCH_ERROR(() => { self.someKey.tid = data.forum.id; }, "该json并暂无tid信息"); AUTO_CATCH_ERROR(() => { let post_list = data.post_list; if (!post_list) { throw new Error(`该PBDATA没有post_list数据, ${data}`); } // 获取楼主id AUTO_CATCH_ERROR(() => { if (GET_URL_ATTR(responseURL, "pn") == "1") { // 楼主id存在1楼数据中 self.lzId = post_list[0].author.id; DEBUGLOG(self.lzId, "lzId"); } }, "无法获取楼主id"); // 获取楼层信息 for (let i = 0; i < post_list.length; i++) { let d = post_list[i]; self.floorDataList[d.floor] = d; } }); }); } } (function main() { // 首次进入,对于不同的页面采取不同的行为 switch (getPageType()) { case PAGE_TYPE.MAINPAGE: // 首次进入帖子主页 INSERT_CSS(STR_CSS_REMOVE + STR_CSS_MAINPAGE); SingleInstanceManager.ORDER_SINGLE(LzlPage); SingleInstanceManager.ORDER_SINGLE(MainPage); SingleInstanceManager.ORDER_SINGLE(SettingDialog); SingleInstanceManager.ORDER_SINGLE(HistoryStateManager); MainPage.getInstance().init(); // @ts-ignore GM.unsafeWindow.setDEBUGLOG = function (/** @type {boolean} */ enable) { GM.setValue(STR_GMKEY_IS_DEBUG, enable); IS_DEBUG = enable; console.log(`DEBUGLOG is ${IS_DEBUG ? "enabled" : "disabled"}`); } break; case PAGE_TYPE.POSTPAGE: DEBUGLOG("评论页未前置处理", STR_DEBUG_LABEL_ERROR); break; case PAGE_TYPE.LZLPAGE: DEBUGLOG("已切换到楼中楼页", STR_DEBUG_LABEL_ERROR); break; default: DEBUGLOG("未知网址", STR_DEBUG_LABEL_ERROR); break; } })() })();