您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
NGA 库,包括工具类、缓存、API
当前为
此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.cn-greasyfork.org/scripts/486070/1351160/NGA%20Library.js
// ==UserScript== // @name NGA Library // @namespace https://greasyfork.org/users/263018 // @version 1.0.3 // @author snyssss // @description NGA 库,包括工具类、缓存、API // @license MIT // @match *://bbs.nga.cn/* // @match *://ngabbs.com/* // @match *://nga.178.com/* // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant unsafeWindow // ==/UserScript== /** * 工具类 */ class Tools { /** * 返回当前值的类型 * @param {*} value 值 * @returns {String} 值的类型 */ static getType = (value) => { return Object.prototype.toString.call(value).slice(8, -1).toLowerCase(); }; /** * 返回当前值是否为指定的类型 * @param {*} value 值 * @param {Array<String>} types 类型名称集合 * @returns {Boolean} 值是否为指定的类型 */ static isType = (value, ...types) => { return types.includes(this.getType(value)); }; /** * 拦截属性 * @param {Object} target 目标对象 * @param {String} property 属性或函数名称 * @param {Function} beforeGet 获取属性前事件 * @param {Function} beforeSet 设置属性前事件 * @param {Function} afterGet 获取属性后事件 * @param {Function} afterSet 设置属性前事件 */ static interceptProperty = ( target, property, { beforeGet, beforeSet, afterGet, afterSet } ) => { // 缓存数据 let source = target[property]; // 判断是否已被拦截 const isIntercepted = (() => { const descriptor = Object.getOwnPropertyDescriptor(target, property); if (descriptor && descriptor.get && descriptor.set) { return true; } return false; })(); // 初始化目标对象的拦截列表 target.interceptions = target.interceptions || {}; target.interceptions[property] = target.interceptions[property] || { beforeGetQueue: [], beforeSetQueue: [], afterGetQueue: [], afterSetQueue: [], }; // 写入事件 Object.entries({ beforeGetQueue: beforeGet, beforeSetQueue: beforeSet, afterGetQueue: afterGet, afterSetQueue: afterSet, }).forEach(([queue, event]) => { if (event) { target.interceptions[property][queue].push(event); } }); // 如果已经有结果,则直接处理写入后操作 if (Object.hasOwn(target, property)) { if (afterSet) { afterSet.apply(target, [source]); } } // 拦截 if (isIntercepted === false) { // 定义属性 Object.defineProperty(target, property, { get: () => { // 获取事件 const { beforeGetQueue, afterGetQueue } = target.interceptions[property]; // 如果是函数 if (this.isType(source, "function")) { return (...args) => { try { // 执行前操作 // 可以在这一步修改参数 // 可以通过在这一步抛出来阻止执行 if (beforeGetQueue) { beforeGetQueue.forEach((event) => { args = event.apply(target, args); }); } // 执行函数 const returnValue = source.apply(target, args); // 返回的可能是一个 Promise const result = returnValue instanceof Promise ? returnValue : Promise.resolve(returnValue); // 执行后操作 if (afterGetQueue) { result.then((value) => { afterGetQueue.forEach((event) => { event.apply(target, [value, args, source]); }); }); } } catch {} }; } try { // 返回前操作 // 可以在这一步修改返回结果 // 可以通过在这一步抛出来返回 undefined let result = source; if (beforeGetQueue) { beforeGetQueue.forEach((event) => { result = event.apply(target, [result]); }); } // 返回后操作 // 实际上是在返回前完成的,并不能叫返回后操作,但是我们可以配合 afterGet 来操作处理后的数据 if (afterGetQueue) { afterGetQueue.forEach((event) => { event.apply(target, [result, source]); }); } // 返回结果 return result; } catch { return undefined; } }, set: (value) => { // 获取事件 const { beforeSetQueue, afterSetQueue } = target.interceptions[property]; try { // 写入前操作 // 可以在这一步修改写入结果 // 可以通过在这一步抛出来写入 undefined let result = value; if (beforeSetQueue) { beforeSetQueue.forEach((event) => { result = event.apply(target, [source, result]); }); } // 写入可能的事件 if (this.isType(source, "object")) { result.interceptions = source.interceptions; } // 写入结果 source = result; // 写入后操作 if (afterSetQueue) { afterSetQueue.forEach((event) => { event.apply(target, [result, value]); }); } } catch { source = undefined; } }, }); } }; /** * 合并数据 * @param {*} target 目标对象 * @param {Array} sources 来源对象集合 * @returns 合并后的对象 */ static merge = (target, ...sources) => { for (const source of sources) { const targetType = this.getType(target); const sourceType = this.getType(source); // 如果来源对象的类型与目标对象不一致,替换为来源对象 if (sourceType !== targetType) { target = source; continue; } // 如果来源对象是数组,直接合并 if (targetType === "array") { target = [...target, ...source]; continue; } // 如果来源对象是对象,合并对象 if (sourceType === "object") { for (const key in source) { if (Object.hasOwn(target, key)) { target[key] = this.merge(target[key], source[key]); } else { target[key] = source[key]; } } continue; } // 其他情况,更新值 target = source; } return target; }; /** * 数组排序 * @param {Array} collection 数据集合 * @param {Array<String | Function>} iterators 迭代器,要排序的属性名或排序函数 */ static sortBy = (collection, ...iterators) => collection.slice().sort((a, b) => { for (let i = 0; i < iterators.length; i += 1) { const iteratee = iterators[i]; const valueA = this.isType(iteratee, "function") ? iteratee(a) : a[iteratee]; const valueB = this.isType(iteratee, "function") ? iteratee(b) : b[iteratee]; if (valueA < valueB) { return -1; } if (valueA > valueB) { return 1; } } return 0; }); /** * 读取论坛数据 * @param {Response} response 请求响应 * @param {Boolean} toJSON 是否转为 JSON 格式 */ static readForumData = async (response, toJSON = true) => { return new Promise(async (resolve) => { const blob = await response.blob(); const reader = new FileReader(); reader.onload = () => { const text = reader.result.replace( "window.script_muti_get_var_store=", "" ); if (toJSON) { try { resolve(JSON.parse(text)); } catch { resolve({}); } return; } resolve(text); }; reader.readAsText(blob, "GBK"); }); }; /** * 获取成对括号的内容 * @param {String} content 内容 * @param {String} keyword 起始位置关键字 * @param {String} start 左括号 * @param {String} end 右括号 * @returns {String} 包含括号的内容 */ static searchPair = (content, keyword, start = "{", end = "}") => { // 获取成对括号的位置 const getLastIndex = (content, position, start = "{", end = "}") => { if (position >= 0) { let nextIndex = position + 1; while (nextIndex < content.length) { if (content[nextIndex] === end) { return nextIndex; } if (content[nextIndex] === start) { nextIndex = getLastIndex(content, nextIndex, start, end); if (nextIndex < 0) { break; } } nextIndex = nextIndex + 1; } } return -1; }; // 起始位置 const str = keyword + start; // 起始下标 const index = content.indexOf(str) + str.length; // 结尾下标 const lastIndex = getLastIndex(content, index, start, end); if (lastIndex >= 0) { return start + content.substring(index, lastIndex) + end; } return null; }; /** * 计算字符串的颜色 * * 采用的是泥潭的颜色方案,参见 commonui.htmlName * @param {String} value 字符串 * @returns {String} RGB代码 */ static generateColor(value) { const hash = (() => { let h = 5381; for (var i = 0; i < value.length; i++) { h = ((h << 5) + h + value.charCodeAt(i)) & 0xffffffff; } return h; })(); const hex = Math.abs(hash).toString(16) + "000000"; const hsv = [ `0x${hex.substring(2, 4)}` / 255, `0x${hex.substring(2, 4)}` / 255 / 2 + 0.25, `0x${hex.substring(4, 6)}` / 255 / 2 + 0.25, ]; const rgb = ((h, s, v) => { const f = (n, k = (n + h / 60) % 6) => v - v * s * Math.max(Math.min(k, 4 - k, 1), 0); return [f(5), f(3), f(1)]; })(hsv[0], hsv[1], hsv[2]); return ["#", ...rgb].reduce((a, b) => { return a + ("0" + b.toString(16)).slice(-2); }); } } /** * 初始化缓存和 API */ const initCacheAndAPI = (() => { // KEY const USER_AGENT_KEY = "USER_AGENT_KEY"; const CLEAR_TIME_KEY = "CLEAR_TIME_KEY"; /** * 数据库名称 */ const name = "NGA_Storage"; /** * 模块列表 */ const modules = { TOPIC_NUM_CACHE: { keyPath: "uid", version: 1, indexes: ["timestamp"], expireTime: 1000 * 60 * 60, persistent: true, }, USER_INFO_CACHE: { keyPath: "uid", version: 1, indexes: ["timestamp"], expireTime: 1000 * 60 * 60, persistent: false, }, USER_IPLOC_CACHE: { keyPath: "uid", version: 1, indexes: ["timestamp"], expireTime: 1000 * 60 * 60, persistent: true, }, PAGE_CACHE: { keyPath: "url", version: 1, indexes: ["timestamp"], expireTime: 1000 * 60 * 10, persistent: false, }, FORUM_POSTED_CACHE: { keyPath: "url", version: 1, indexes: ["timestamp"], expireTime: 1000 * 60 * 60 * 24, persistent: true, }, }; /** * IndexedDB * * 简单制造轮子,暂不打算引入 dexie.js,待其云方案正式推出后再考虑 */ class DBStorage { /** * 当前实例 */ instance = null; /** * 是否支持 */ isSupport() { return unsafeWindow.indexedDB !== undefined; } /** * 打开数据库并创建表 * @returns {Promise<IDBDatabase>} 实例 */ async open() { // 创建实例 if (this.instance === null) { // 声明一个数组,用于等待全部表处理完毕 const queue = []; // 创建实例 await new Promise((resolve, reject) => { // 版本 const version = Object.values(modules) .map(({ version }) => version) .reduce((a, b) => Math.max(a, b), 0); // 创建请求 const request = unsafeWindow.indexedDB.open(name, version); // 创建或者升级表 request.onupgradeneeded = (event) => { this.instance = event.target.result; const transaction = event.target.transaction; const oldVersion = event.oldVersion; Object.entries(modules).forEach(([key, values]) => { if (values.version > oldVersion) { queue.push(this.createOrUpdateStore(key, values, transaction)); } }); }; // 成功后处理 request.onsuccess = (event) => { this.instance = event.target.result; resolve(); }; // 失败后处理 request.onerror = () => { reject(); }; }); // 等待全部表处理完毕 await Promise.all(queue); } // 返回实例 return this.instance; } /** * 获取表 * @param {String} name 表名 * @param {IDBTransaction} transaction 事务,空则根据表名创建新事务 * @param {String} mode 事务模式,默认为只读 * @returns {Promise<IDBObjectStore>} 表 */ async getStore(name, transaction = null, mode = "readonly") { const db = await this.open(); if (transaction === null) { transaction = db.transaction(name, mode); } return transaction.objectStore(name); } /** * 创建或升级表 * @param {String} name 表名 * @param {IDBTransaction} transaction 事务,空则根据表名创建新事务 * @returns {Promise} */ async createOrUpdateStore(name, { keyPath, indexes }, transaction) { const db = transaction.db; const data = []; // 检查是否存在表,如果存在,缓存数据并删除旧表 if (db.objectStoreNames.contains(name)) { // 获取并缓存全部数据 const result = await this.bulkGet(name, [], transaction); if (result) { data.push(...result); } // 删除旧表 db.deleteObjectStore(name); } // 创建表 const store = db.createObjectStore(name, { keyPath, }); // 创建索引 if (indexes) { indexes.forEach((index) => { store.createIndex(index, index); }); } // 迁移数据 if (data.length > 0) { await this.bulkAdd(name, data, transaction); } } /** * 插入指定表的数据 * @param {String} name 表名 * @param {*} data 数据 * @param {IDBTransaction} transaction 事务,空则根据表名创建新事务 * @returns {Promise} */ async add(name, data, transaction = null) { // 获取表 const store = await this.getStore(name, transaction, "readwrite"); // 插入数据 const result = await new Promise((resolve, reject) => { // 创建请求 const request = store.add(data); // 成功后处理 request.onsuccess = (event) => { resolve(event.target.result); }; // 失败后处理 request.onerror = (event) => { reject(event); }; }); // 返回结果 return result; } /** * 删除指定表的数据 * @param {String} name 表名 * @param {String} key 主键 * @param {IDBTransaction} transaction 事务,空则根据表名创建新事务 * @returns {Promise} */ async delete(name, key, transaction = null) { // 获取表 const store = await this.getStore(name, transaction, "readwrite"); // 删除数据 const result = await new Promise((resolve, reject) => { // 创建请求 const request = store.delete(key); // 成功后处理 request.onsuccess = (event) => { resolve(event.target.result); }; // 失败后处理 request.onerror = (event) => { reject(event); }; }); // 返回结果 return result; } /** * 插入或修改指定表的数据 * @param {String} name 表名 * @param {*} data 数据 * @param {IDBTransaction} transaction 事务,空则根据表名创建新事务 * @returns {Promise} */ async put(name, data, transaction = null) { // 获取表 const store = await this.getStore(name, transaction, "readwrite"); // 插入或修改数据 const result = await new Promise((resolve, reject) => { // 创建请求 const request = store.put(data); // 成功后处理 request.onsuccess = (event) => { resolve(event.target.result); }; // 失败后处理 request.onerror = (event) => { reject(event); }; }); // 返回结果 return result; } /** * 获取指定表的数据 * @param {String} name 表名 * @param {String} key 主键 * @param {IDBTransaction} transaction 事务,空则根据表名创建新事务 * @returns {Promise} 数据 */ async get(name, key, transaction = null) { // 获取表 const store = await this.getStore(name, transaction); // 查询数据 const result = await new Promise((resolve, reject) => { // 创建请求 const request = store.get(key); // 成功后处理 request.onsuccess = (event) => { resolve(event.target.result); }; // 失败后处理 request.onerror = (event) => { reject(event); }; }); // 返回结果 return result; } /** * 批量插入指定表的数据 * @param {String} name 表名 * @param {Array} data 数据集合 * @param {IDBTransaction} transaction 事务,空则根据表名创建新事务 * @returns {Promise<number>} 成功数量 */ async bulkAdd(name, data, transaction = null) { // 等待操作结果 const result = await Promise.all( data.map((item) => this.add(name, item, transaction) .then(() => true) .catch(() => false) ) ); // 返回受影响的数量 return result.filter((item) => item).length; } /** * 批量删除指定表的数据 * @param {String} name 表名 * @param {Array<String>} keys 主键集合,空则删除全部 * @param {IDBTransaction} transaction 事务,空则根据表名创建新事务 * @returns {Promise<number>} 成功数量,删除全部时返回 -1 */ async bulkDelete(name, keys = [], transaction = null) { // 如果 keys 为空,删除全部数据 if (keys.length === 0) { // 获取表 const store = await this.getStore(name, transaction, "readwrite"); // 清空数据 await new Promise((resolve, reject) => { // 创建请求 const request = store.clear(); // 成功后处理 request.onsuccess = (event) => { resolve(event.target.result); }; // 失败后处理 request.onerror = (event) => { reject(event); }; }); return -1; } // 等待操作结果 const result = await Promise.all( keys.map((item) => this.delete(name, item, transaction) .then(() => true) .catch(() => false) ) ); // 返回受影响的数量 return result.filter((item) => item).length; } /** * 批量插入或修改指定表的数据 * @param {String} name 表名 * @param {Array} data 数据集合 * @param {IDBTransaction} transaction 事务,空则根据表名创建新事务 * @returns {Promise<number>} 成功数量 */ async bulkPut(name, data, transaction = null) { // 等待操作结果 const result = await Promise.all( data.map((item) => this.put(name, item, transaction) .then(() => true) .catch(() => false) ) ); // 返回受影响的数量 return result.filter((item) => item).length; } /** * 批量获取指定表的数据 * @param {String} name 表名 * @param {Array<String>} keys 主键集合,空则获取全部 * @param {IDBTransaction} transaction 事务,空则根据表名创建新事务 * @returns {Promise<Array>} 数据集合 */ async bulkGet(name, keys = [], transaction = null) { // 如果 keys 为空,查询全部数据 if (keys.length === 0) { // 获取表 const store = await this.getStore(name, transaction); // 查询数据 const result = await new Promise((resolve, reject) => { // 创建请求 const request = store.getAll(); // 成功后处理 request.onsuccess = (event) => { resolve(event.target.result || []); }; // 失败后处理 request.onerror = (event) => { reject(event); }; }); // 返回结果 return result; } // 返回符合的结果 const result = []; await Promise.all( keys.map((key) => this.get(name, key, transaction) .then((item) => { result.push(item); }) .catch(() => {}) ) ); return result; } } /** * 油猴存储 * * 虽然使用了不支持 Promise 的 GM_getValue 与 GM_setValue,但是为了配合 IndexedDB,统一视为 Promise */ class GMStorage extends DBStorage { /** * 插入指定表的数据 * @param {String} name 表名 * @param {*} data 数据 * @returns {Promise} */ async add(name, data) { // 如果不在模块列表里,写入全部数据 if (Object.hasOwn(modules, name) === false) { return GM_setValue(name, data); } // 如果支持 IndexedDB,使用 IndexedDB if (super.isSupport()) { return super.add(name, data); } // 获取对应的主键 const keyPath = modules[name].keyPath; const key = data[keyPath]; // 如果数据中不包含主键,抛出异常 if (key === undefined) { throw new Error(); } // 获取全部数据 const values = GM_getValue(name, {}); // 如果对应主键已存在,抛出异常 if (Object.hasOwn(values, key)) { throw new Error(); } // 插入数据 values[key] = data; // 保存数据 GM_setValue(name, values); } /** * 删除指定表的数据 * @param {String} name 表名 * @param {String} key 主键 * @returns {Promise} */ async delete(name, key) { // 如果不在模块列表里,忽略 key,删除全部数据 if (Object.hasOwn(modules, name) === false) { return GM_setValue(name, {}); } // 如果支持 IndexedDB,使用 IndexedDB if (super.isSupport()) { return super.delete(name, key); } // 获取全部数据 const values = GM_getValue(name, {}); // 如果对应主键不存在,抛出异常 if (Object.hasOwn(values, key) === false) { throw new Error(); } // 删除数据 delete values[key]; // 保存数据 GM_setValue(name, values); } /** * 插入或修改指定表的数据 * @param {String} name 表名 * @param {*} data 数据 * @returns {Promise} */ async put(name, data) { // 如果不在模块列表里,写入全部数据 if (Object.hasOwn(modules, name) === false) { return GM_setValue(name, data); } // 如果支持 IndexedDB,使用 IndexedDB if (super.isSupport()) { return super.put(name, data); } // 获取对应的主键 const keyPath = modules[name].keyPath; const key = data[keyPath]; // 如果数据中不包含主键,抛出异常 if (key === undefined) { throw new Error(); } // 获取全部数据 const values = GM_getValue(name, {}); // 插入或修改数据 values[key] = data; // 保存数据 GM_setValue(name, values); } /** * 获取指定表的数据 * @param {String} name 表名 * @param {String} key 主键 * @returns {Promise} 数据 */ async get(name, key) { // 如果不在模块列表里,忽略 key,返回全部数据 if (Object.hasOwn(modules, name) === false) { return GM_getValue(name); } // 如果支持 IndexedDB,使用 IndexedDB if (super.isSupport()) { return super.get(name, key); } // 获取全部数据 const values = GM_getValue(name, {}); // 如果对应主键不存在,抛出异常 if (Object.hasOwn(values, key) === false) { throw new Error(); } // 返回结果 return values[key]; } /** * 批量插入指定表的数据 * @param {String} name 表名 * @param {Array} data 数据集合 * @returns {Promise<number>} 成功数量 */ async bulkAdd(name, data) { // 如果不在模块列表里,写入全部数据 if (Object.hasOwn(modules, name) === false) { return GM_setValue(name, {}); } // 如果支持 IndexedDB,使用 IndexedDB if (super.isSupport()) { return super.bulkAdd(name, data); } // 获取对应的主键 const keyPath = modules[name].keyPath; // 获取全部数据 const values = GM_getValue(name, {}); // 添加数据 const result = data.map((item) => { const key = item[keyPath]; // 如果数据中不包含主键,抛出异常 if (key === undefined) { return false; } // 如果对应主键已存在,抛出异常 if (Object.hasOwn(values, key)) { return false; } // 插入数据 values[key] = item; return true; }); // 保存数据 GM_setValue(name, values); // 返回受影响的数量 return result.filter((item) => item).length; } /** * 批量删除指定表的数据 * @param {String} name 表名 * @param {Array<String>} keys 主键集合,空则删除全部 * @returns {Promise<number>} 成功数量,删除全部时返回 -1 */ async bulkDelete(name, keys = []) { // 如果不在模块列表里,忽略 keys,删除全部数据 if (Object.hasOwn(modules, name) === false) { return GM_setValue(name, {}); } // 如果支持 IndexedDB,使用 IndexedDB if (super.isSupport()) { return super.bulkDelete(name, keys); } // 如果 keys 为空,删除全部数据 if (keys.length === 0) { GM_setValue(name, {}); return -1; } // 获取全部数据 const values = GM_getValue(name, {}); // 删除数据 const result = keys.map((key) => { // 如果对应主键不存在,抛出异常 if (Object.hasOwn(values, key) === false) { return false; } // 删除数据 delete values[key]; return true; }); // 保存数据 GM_setValue(name, values); // 返回受影响的数量 return result.filter((item) => item).length; } /** * 批量插入或修改指定表的数据 * @param {String} name 表名 * @param {Array} data 数据集合 * @returns {Promise<number>} 成功数量 */ async bulkPut(name, data) { // 如果不在模块列表里,写入全部数据 if (Object.hasOwn(modules, name) === false) { return GM_setValue(name, data); } // 如果支持 IndexedDB,使用 IndexedDB if (super.isSupport()) { return super.bulkPut(name, keys); } // 获取对应的主键 const keyPath = modules[name].keyPath; // 获取全部数据 const values = GM_getValue(name, {}); // 添加数据 const result = data.map((item) => { const key = item[keyPath]; // 如果数据中不包含主键,抛出异常 if (key === undefined) { return false; } // 插入数据 values[key] = item; return true; }); // 保存数据 GM_setValue(name, values); // 返回受影响的数量 return result.filter((item) => item).length; } /** * 批量获取指定表的数据,如果不在模块列表里,返回全部数据 * @param {String} name 表名 * @param {Array<String>} keys 主键集合,空则获取全部 * @returns {Promise<Array>} 数据集合 */ async bulkGet(name, keys = []) { // 如果不在模块列表里,忽略 keys,返回全部数据 if (Object.hasOwn(modules, name) === false) { return GM_getValue(name); } // 如果支持 IndexedDB,使用 IndexedDB if (super.isSupport()) { return super.bulkGet(name, keys); } // 获取全部数据 const values = GM_getValue(name, {}); // 如果 keys 为空,返回全部数据 if (keys.length === 0) { return Object.values(values); } // 返回符合的结果 const result = []; keys.forEach((key) => { if (Object.hasOwn(values, key)) { result.push(values[key]); } }); return result; } } /** * 缓存管理 * * 在存储的基础上,增加了过期时间和持久化选项,自动清理缓存 */ class Cache extends GMStorage { /** * 插入指定表的数据,并增加 timestamp * @param {String} name 表名 * @param {*} data 数据 * @returns {Promise} */ async add(name, data) { // 如果在模块里,增加 timestamp if (Object.hasOwn(modules, name)) { data.timestamp = data.timestamp || new Date().getTime(); } return super.add(name, data); } /** * 插入或修改指定表的数据,并增加 timestamp * @param {String} name 表名 * @param {*} data 数据 * @returns {Promise} */ async put(name, data) { // 如果在模块里,增加 timestamp if (Object.hasOwn(modules, name)) { data.timestamp = data.timestamp || new Date().getTime(); } return super.put(name, data); } /** * 获取指定表的数据,并移除过期数据 * @param {String} name 表名 * @param {String} key 主键 * @returns {Promise} 数据 */ async get(name, key) { // 获取数据 const value = await super.get(name, key).catch(() => null); // 如果不在模块里,直接返回结果 if (Object.hasOwn(modules, name) === false) { return value; } // 如果有结果的话,移除超时数据 if (value) { // 读取模块配置 const { expireTime, persistent } = modules[name]; // 持久化或未超时 if (persistent || value.timestamp + expireTime > new Date().getTime()) { return value; } // 移除超时数据 await super.delete(name, key); } return null; } /** * 批量插入指定表的数据,并增加 timestamp * @param {String} name 表名 * @param {Array} data 数据集合 * @returns {Promise<number>} 成功数量 */ async bulkAdd(name, data) { // 如果在模块里,增加 timestamp if (Object.hasOwn(modules, name)) { data.forEach((item) => { item.timestamp = item.timestamp || new Date().getTime(); }); } return super.bulkAdd(name, data); } /** * 批量删除指定表的数据 * @param {String} name 表名 * @param {Array<String>} keys 主键集合,空则删除全部 * @param {boolean} force 是否强制删除,否则只删除过期数据 * @returns {Promise<number>} 成功数量,删除全部时返回 -1 */ async bulkDelete(name, keys = [], force = false) { // 如果不在模块里,强制删除 if (Object.hasOwn(modules, name) === false) { force = true; } // 强制删除 if (force) { return super.bulkDelete(name, keys); } // 批量获取指定表的数据,并移除过期数据 const result = this.bulkGet(name, keys); // 返回成功数量 if (keys.length === 0) { return -1; } return keys.length - result.length; } /** * 批量插入或修改指定表的数据,并增加 timestamp * @param {String} name 表名 * @param {Array} data 数据集合 * @returns {Promise<number>} 成功数量 */ async bulkPut(name, data) { // 如果在模块里,增加 timestamp if (Object.hasOwn(modules, name)) { data.forEach((item) => { item.timestamp = item.timestamp || new Date().getTime(); }); } return super.bulkPut(name, data); } /** * 批量获取指定表的数据,并移除过期数据 * @param {String} name 表名 * @param {Array<String>} keys 主键集合,空则获取全部 * @returns {Promise<Array>} 数据集合 */ async bulkGet(name, keys = []) { // 获取数据 const values = await super.bulkGet(name, keys).catch(() => []); // 如果不在模块里,直接返回结果 if (Object.hasOwn(modules, name) === false) { return values; } // 读取模块配置 const { keyPath, expireTime, persistent } = modules[name]; // 筛选出超时数据 const result = []; const expired = []; values.forEach((value) => { // 持久化或未超时 if (persistent || value.timestamp + expireTime > new Date().getTime()) { result.push(value); return; } // 记录超时数据 expired.push(value[keyPath]); }); // 移除超时数据 await super.bulkDelete(name, expired); // 返回结果 return result; } } /** * API */ class API { /** * 缓存管理 */ cache; /** * 初始化并绑定缓存管理 * @param {Cache} cache 缓存管理 */ constructor(cache) { this.cache = cache; } /** * 简单的统一请求 * @param {String} url 请求地址 * @param {Object} config 请求参数 * @param {Boolean} toJSON 是否转为 JSON 格式 */ async request(url, config = {}, toJSON = true) { const userAgent = (await this.cache.get(USER_AGENT_KEY)) || "Nga_Official"; const response = await fetch(url, { headers: { "X-User-Agent": userAgent, }, ...config, }); const result = await Tools.readForumData(response, toJSON); return result; } /** * 获取用户主题数量 * @param {number} uid 用户 ID */ async getTopicNum(uid) { const name = "TOPIC_NUM_CACHE"; const { expireTime } = modules[name]; const api = `/thread.php?lite=js&authorid=${uid}`; const cache = await this.cache.get(name, uid); // 仍在缓存期间内,直接返回 if (cache) { const expired = cache.timestamp + expireTime < new Date().getTime(); if (expired === false) { return cache.count; } } // 请求数据 const result = await this.request(api); // 服务器可能返回错误,遇到这种情况下,需要保留缓存 const count = (() => { if (result.data) { return result.data.__ROWS || 0; } if (cache) { return cache.count; } return 0; })(); // 更新缓存 this.cache.put(name, { uid, count, }); return count; } /** * 获取用户信息 * @param {number} uid 用户 ID */ async getUserInfo(uid) { const name = "USER_INFO_CACHE"; const api = `/nuke.php?lite=js&__lib=ucp&__act=get&uid=${uid}`; const cache = await this.cache.get(name, uid); if (cache) { return cache.data; } const result = await this.request(api, { credentials: "omit", }); const data = result.data ? result.data[0] : null; if (data) { this.cache.put(name, { uid, data, }); } return data || {}; } /** * 获取属地列表 * @param {number} uid 用户 ID */ async getIpLocations(uid) { const name = "USER_IPLOC_CACHE"; const { expireTime } = modules[name]; const cache = await this.cache.get(name, uid); // 仍在缓存期间内,直接返回 if (cache) { const expired = cache.timestamp + expireTime < new Date().getTime(); if (expired === false) { return cache.data; } } // 属地列表 const data = cache ? cache.data : []; // 请求属地 const { ipLoc } = await this.getUserInfo(uid); // 写入缓存 if (ipLoc) { const index = data.findIndex((item) => { return item.ipLoc === ipLoc; }); if (index >= 0) { data.splice(index, 1); } data.unshift({ ipLoc, timestamp: new Date().getTime(), }); this.cache.put(name, { uid, data, }); } // 返回结果 return data; } /** * 获取帖子内容、用户信息(主要是发帖数量,常规的获取用户信息方法不一定有结果)、版面声望 * @param {number} tid 主题 ID * @param {number} pid 回复 ID */ async getPostInfo(tid, pid) { const name = "PAGE_CACHE"; const api = pid ? `/read.php?pid=${pid}` : `/read.php?tid=${tid}`; const cache = await this.cache.get(name, api); if (cache) { return cache.data; } const result = await this.request(api, {}, false); const parser = new DOMParser(); const doc = parser.parseFromString(result, "text/html"); // 验证帖子正常 const verify = doc.querySelector("#m_posts"); if (verify === null) { return {}; } // 声明返回值 const data = {}; // 取得顶楼 UID data.uid = (() => { const ele = doc.querySelector("#postauthor0"); if (ele) { const res = ele.getAttribute("href").match(/uid=(\S+)/); if (res) { return res[1]; } } return 0; })(); // 取得顶楼标题 data.subject = doc.querySelector("#postsubject0").innerHTML; // 取得顶楼内容 data.content = doc.querySelector("#postcontent0").innerHTML; // 非匿名用户可以继续取得用户信息和版面声望 if (data.uid > 0) { // 取得用户信息 data.userInfo = (() => { const text = Tools.searchPair(result, `"${data.uid}":`); if (text) { try { return JSON.parse(text); } catch { return null; } } return null; })(); // 取得用户声望 data.reputation = (() => { const reputations = (() => { const text = Tools.searchPair(result, `"__REPUTATIONS":`); if (text) { try { return JSON.parse(text); } catch { return null; } } return null; })(); if (reputations) { for (let fid in reputations) { return reputations[fid][data.uid] || 0; } } return NaN; })(); } // 写入缓存 this.cache.put(name, { url: api, data, }); // 返回结果 return data; } /** * 获取版面信息 * @param {number} fid 版面 ID */ async getForumInfo(fid) { if (Number.isNaN(fid)) { return null; } const api = `/thread.php?lite=js&fid=${fid}`; const result = await this.request(api); const info = result.data ? result.data.__F : null; return info; } /** * 获取版面发言记录 * @param {number} fid 版面 ID * @param {number} uid 用户 ID */ async getForumPosted(fid, uid) { const name = "FORUM_POSTED_CACHE"; const { expireTime } = modules[name]; const api = `/thread.php?lite=js&authorid=${uid}&fid=${fid}`; const cache = await this.cache.get(name, api); if (cache) { // 发言是无法撤销的,只要有记录就永远不需要再获取 // 手动处理没有记录的缓存数据 const expired = cache.timestamp + expireTime < new Date().getTime(); if (expired && cache.data === false) { await this.cache.delete(name, api); } return cache.data; } let isComplete = false; let isBusy = false; const func = async (url) => { if (isComplete || isBusy) { return; } const result = await this.request(url, {}, false); // 将所有匹配的 FID 写入缓存,即使并不在设置里 const matched = result.match(/"fid":(-?\d+),/g); if (matched) { const list = [ ...new Set( matched.map((item) => parseInt(item.match(/-?\d+/)[0], 10)) ), ]; list.forEach((item) => { const key = api.replace(`&fid=${fid}`, `&fid=${item}`); // 写入缓存 this.cache.put(name, { url: key, data: true, }); // 已有结果,无需继续查询 if (fid === item) { isComplete = true; } }); } // 泥潭给版面查询接口增加了限制,经常会出现“服务器忙,请稍后重试”的错误 if (result.indexOf("服务器忙") > 0) { isBusy = true; } }; // 先获取回复记录的第一页,顺便可以获取其他版面的记录 // 没有再通过版面接口获取,避免频繁出现“服务器忙,请稍后重试”的错误 await func(api.replace(`&fid=${fid}`, `&searchpost=1`)); await func(api + "&searchpost=1"); await func(api); // 无论成功与否都写入缓存 if (isComplete === false) { // 遇到服务器忙的情况,手动调整缓存时间至 1 小时 const timestamp = isBusy ? new Date().getTime() - (expireTime - 1000 * 60 * 60) : new Date().getTime(); // 写入失败缓存 this.cache.put(name, { url: api, data: false, timestamp, }); } return isComplete; } } /** * 注册脚本菜单 * @param {Cache} cache 缓存管理 */ const registerMenu = async (cache) => { const data = (await cache.get(USER_AGENT_KEY)) || "Nga_Official"; GM_registerMenuCommand(`修改UA:${data}`, () => { const value = prompt("修改UA", data); if (value) { cache.put(USER_AGENT_KEY, value); location.reload(); } }); }; /** * 自动清理缓存 * @param {Cache} cache 缓存管理 */ const autoClear = async (cache) => { const data = await cache.get(CLEAR_TIME_KEY); const now = new Date(); const clearTime = new Date(data || 0); const isToday = now.getDate() === clearTime.getDate() && now.getMonth() === clearTime.getMonth() && now.getFullYear() === clearTime.getFullYear(); if (isToday) { return; } await Promise.all( Object.keys(modules).map((name) => cache.bulkDelete(name)) ); await cache.put(CLEAR_TIME_KEY, now.getTime()); }; // 初始化事件 return () => { // 防止重复初始化 if (unsafeWindow.NLibrary === undefined) { // 初始化缓存和 API const cache = new Cache(); const api = new API(cache); // 自动清理缓存 autoClear(cache); // 写入全局变量 unsafeWindow.NLibrary = { cache, api, }; } const { cache, api } = unsafeWindow.NLibrary; // 注册脚本菜单 registerMenu(cache); // 返回结果 return { cache, api, }; }; })();