UserscriptAPI

My API for userscripts.

Tento skript by neměl být instalován přímo. Jedná se o knihovnu, kterou by měly jiné skripty využívat pomocí meta příkazu // @require https://update.greatest.deepsurf.us/scripts/409641/1435266/UserscriptAPI.js

  1. /* exported UserscriptAPI */
  2. /**
  3. * UserscriptAPI
  4. *
  5. * 需要引入模块方可工作,详见 `README.md`。
  6. * @version 2.2.1.20230314
  7. * @author Laster2800
  8. * @see {@link https://gitee.com/liangjiancang/userscript/tree/master/lib/UserscriptAPI UserscriptAPI}
  9. */
  10. class UserscriptAPI {
  11. /** @type {{[name: string]: Function}} 可访问模块 */
  12. static #modules = {}
  13. /** @type {string[]} 待添加模块样式队列 */
  14. #moduleCssQueue = []
  15.  
  16. /**
  17. * @param {Object} [options] 选项
  18. * @param {string} [options.id='default'] 标识符
  19. * @param {string} [options.label] 日志标签,为空时不设置标签
  20. * @param {Object} [options.wait] `wait` API 默认选项(默认值见构造器代码)
  21. * @param {Object} [options.wait.condition] `wait` 条件 API 默认选项
  22. * @param {Object} [options.wait.element] `wait` 元素 API 默认选项
  23. * @param {number} [options.fadeTime=400] UI 渐变时间
  24. */
  25. constructor(options) {
  26. this.options = {
  27. id: 'default',
  28. label: null,
  29. fadeTime: 400,
  30. ...options,
  31. wait: {
  32. condition: {
  33. callback: result => this.logger.info(result),
  34. interval: 100,
  35. timeout: 10000,
  36. onTimeout: options => this.logger[options.stopOnTimeout ? 'error' : 'warn']('executeAfterConditionPassed: TIMEOUT', options),
  37. stopOnTimeout: true,
  38. stopCondition: null,
  39. onStop: options => this.logger.error('executeAfterConditionPassed: STOP', options),
  40. stopInterval: 50,
  41. stopTimeout: 0,
  42. onError: (options, e) => this.logger.error('executeAfterConditionPassed: ERROR', options, e),
  43. stopOnError: true,
  44. timePadding: 0,
  45. ...options?.wait?.condition,
  46. },
  47. element: {
  48. base: document,
  49. exclude: null,
  50. callback: el => this.logger.info(el),
  51. subtree: true,
  52. multiple: false,
  53. repeat: false,
  54. throttleWait: 100,
  55. timeout: 10000,
  56. onTimeout: options => this.logger[options.stopOnTimeout ? 'error' : 'warn']('executeAfterElementLoaded: TIMEOUT', options),
  57. stopOnTimeout: false,
  58. stopCondition: null,
  59. onStop: options => this.logger.error('executeAfterElementLoaded: STOP', options),
  60. onError: (options, e) => this.logger.error('executeAfterElementLoaded: ERROR', options, e),
  61. stopOnError: true,
  62. timePadding: 0,
  63. ...options?.wait?.element,
  64. },
  65. },
  66. }
  67.  
  68. /** @type {UserscriptAPIDom} */
  69. this.dom = this.#getModuleInstance('dom')
  70. /** @type {UserscriptAPIMessage} */
  71. this.message = this.#getModuleInstance('message')
  72. /** @type {UserscriptAPIWait} */
  73. this.wait = this.#getModuleInstance('wait')
  74. /** @type {UserscriptAPIWeb} */
  75. this.web = this.#getModuleInstance('web')
  76.  
  77. if (!this.message) {
  78. this.message = {
  79. api: this,
  80. alert: this.base.alert,
  81. confirm: this.base.confirm,
  82. prompt: this.base.prompt,
  83. }
  84. }
  85.  
  86. for (const css of this.#moduleCssQueue) {
  87. this.base.addStyle(css)
  88. }
  89. }
  90.  
  91. /**
  92. * 注册模块
  93. * @param {string} name 模块名称
  94. * @param {Object} module 模块类
  95. */
  96. static registerModule(name, module) {
  97. this.#modules[name] = module
  98. }
  99.  
  100. /**
  101. * 获取模块实例
  102. * @param {string} name 模块名称
  103. * @returns {Object} 模块实例,无对应模块时返回 `null`
  104. */
  105. #getModuleInstance(name) {
  106. const module = UserscriptAPI.#modules[name]
  107. return module ? new module(this) : null
  108. }
  109.  
  110. /**
  111. * 初始化模块样式(仅应在模块构造器中使用)
  112. * @param {string} css 样式
  113. */
  114. initModuleStyle(css) {
  115. this.#moduleCssQueue.push(css)
  116. }
  117.  
  118. /**
  119. * UserscriptAPIBase
  120. * @version 1.3.0.20240827
  121. */
  122. base = new class UserscriptAPIBase {
  123. /**
  124. * @param {UserscriptAPI} api `UserscriptAPI`
  125. */
  126. constructor(api) {
  127. this.api = api
  128. }
  129.  
  130. /**
  131. * 添加样式
  132. * @param {string} css 样式
  133. * @param {Document | DocumentFragment} [doc=document] 文档
  134. * @returns {HTMLStyleElement} `<style>`
  135. */
  136. addStyle(css, doc = document) {
  137. const { api } = this
  138. let style = null
  139. if (doc instanceof Document) {
  140. style = doc.createElement('style')
  141. style.className = `${api.options.id}-style`
  142. style.textContent = css
  143. const parent = doc.head || doc.documentElement
  144. if (parent) {
  145. parent.append(style)
  146. } else { // 极端情况下会出现,DevTools 网络+CPU 双限制可模拟
  147. api.wait?.waitForConditionPassed({
  148. condition: () => doc.head || doc.documentElement,
  149. timeout: 0,
  150. }).then(parent => parent.append(style))
  151. }
  152. } else if (doc instanceof DocumentFragment) {
  153. style = document.createElement('style')
  154. style.className = `${api.options.id}-style`
  155. style.textContent = css
  156. doc.appendChild(style)
  157. }
  158. return style
  159. }
  160.  
  161. /**
  162. * 判断给定 URL 是否匹配
  163. * @param {RegExp | RegExp[]} regex 用于判断是否匹配的正则表达式,或正则表达式数组
  164. * @param {'OR' | 'AND'} [mode='OR'] 匹配模式
  165. * @returns {boolean} 是否匹配
  166. */
  167. urlMatch(regex, mode = 'OR') {
  168. let result = false
  169. const { href } = location
  170. if (Array.isArray(regex)) {
  171. if (regex.length > 0) {
  172. if (mode === 'AND') {
  173. result = true
  174. for (const ex of regex) {
  175. if (!ex.test(href)) {
  176. result = false
  177. break
  178. }
  179. }
  180. } else if (mode === 'OR') {
  181. for (const ex of regex) {
  182. if (ex.test(href)) {
  183. result = true
  184. break
  185. }
  186. }
  187. }
  188. }
  189. } else {
  190. result = regex.test(href)
  191. }
  192. return result
  193. }
  194.  
  195. /**
  196. * 初始化 `urlchange` 事件
  197. * @example
  198. * window.addEventListener('urlchange', e => { ... })
  199. * window.addEventListener('urlchange', e => e.stopPropagation(), true)
  200. * window.onurlchange = function(e) { ... }
  201. * @see {@link https://stackoverflow.com/a/52809105 How to detect if URL has changed after hash in JavaScript}
  202. * @see {@link https://stackoverflow.com/a/69342637 Event bubbles before captured on `window`}
  203. */
  204. initUrlchangeEvent() {
  205. const win = typeof unsafeWindow === 'object' ? unsafeWindow : window
  206. if (win[Symbol.for('onurlchange')] === undefined) {
  207. let url = new URL(location.href)
  208. const dispatchEvent = () => {
  209. const event = new CustomEvent('urlchange', {
  210. detail: { prev: url, curr: new URL(location.href) },
  211. bubbles: true,
  212. })
  213. url = event.detail.curr
  214. if (typeof window.onurlchange === 'function') { // 若直接调用则 eventPhase 不对,且会有一些其他问题
  215. // 这一方案只能让事件处理器属性在最后被激活,但正确的顺序是:https://stackoverflow.com/a/49806959
  216. // 要实现正确的顺序,需用 defineProperty 定义 onurlchange,但 Tampermonkey 已经定义了该属性
  217. // 尽管目前 Tampermonkey 定义的属性是可写的,但为了向前兼容性及简化代码考虑,决定采用当前方案
  218. window.addEventListener('urlchange', window.onurlchange, { once: true })
  219. }
  220. document.dispatchEvent(event) // 在 window 上 dispatch 不能确保在冒泡前捕获,至少目前是这样
  221. }
  222.  
  223. history.pushState = (f => (...args) => {
  224. const ret = Reflect.apply(f, history, args)
  225. dispatchEvent()
  226. return ret
  227. })(history.pushState)
  228. history.replaceState = (f => (...args) => {
  229. const ret = Reflect.apply(f, history, args)
  230. dispatchEvent()
  231. return ret
  232. })(history.replaceState)
  233. window.addEventListener('popstate', () => {
  234. dispatchEvent()
  235. })
  236. win[Symbol.for('onurlchange')] = true
  237. }
  238. }
  239.  
  240. /**
  241. * 生成消抖函数
  242. * @param {Function} fn 目标函数
  243. * @param {number} [wait=0] 消抖延迟
  244. * @param {Object} [options] 选项
  245. * @param {boolean} [options.leading] 是否在延迟开始前调用目标函数
  246. * @param {boolean} [options.trailing=true] 是否在延迟结束后调用目标函数
  247. * @param {number} [options.maxWait=0] 最大延迟时间(非准确),`0` 表示禁用
  248. * @returns {Function} 消抖函数 `debounced`,可调用 `debounced.cancel()` 取消执行
  249. */
  250. debounce(fn, wait = 0, options = {}) {
  251. options = {
  252. leading: false,
  253. trailing: true,
  254. maxWait: 0,
  255. ...options,
  256. }
  257.  
  258. let tid = null
  259. let start = null
  260. let execute = null
  261. let callback = null
  262.  
  263. /** @this {*} thisArg */
  264. function debounced(...args) {
  265. execute = () => {
  266. Reflect.apply(fn, this, args)
  267. execute = null
  268. }
  269. callback = () => {
  270. if (options.trailing) {
  271. execute?.()
  272. }
  273. tid = null
  274. start = null
  275. }
  276.  
  277. if (tid) {
  278. clearTimeout(tid)
  279. if (options.maxWait > 0 && Date.now() - start > options.maxWait) {
  280. callback()
  281. }
  282. }
  283.  
  284. if (!tid && options.leading) {
  285. execute?.()
  286. }
  287.  
  288. if (!start) {
  289. start = Date.now()
  290. }
  291.  
  292. tid = setTimeout(callback, wait)
  293. }
  294.  
  295. debounced.cancel = function() {
  296. if (tid) {
  297. clearTimeout(tid)
  298. tid = null
  299. start = null
  300. }
  301. }
  302.  
  303. return debounced
  304. }
  305.  
  306. /**
  307. * 生成节流函数
  308. * @param {Function} fn 目标函数
  309. * @param {number} [wait=0] 节流延迟(非准确)
  310. * @returns {Function} 节流函数 `throttled`,可调用 `throttled.cancel()` 取消执行
  311. */
  312. throttle(fn, wait = 0) {
  313. return this.debounce(fn, wait, {
  314. leading: true,
  315. trailing: true,
  316. maxWait: wait,
  317. })
  318. }
  319.  
  320. /**
  321. * 创建基础提醒对话框(异步)
  322. *
  323. * 若没有引入 `message` 模块,可使用 `api.message.alert()` 引用该方法。
  324. * @param {string} msg 信息
  325. */
  326. alert(msg) {
  327. const { label } = this.api.options
  328. return new Promise(resolve => {
  329. resolve(alert(`${label ? `${label}\n\n` : ''}${msg}`))
  330. })
  331. }
  332.  
  333. /**
  334. * 创建基础确认对话框(异步)
  335. *
  336. * 若没有引入 `message` 模块,可使用 `api.message.confirm()` 引用该方法。
  337. * @param {string} msg 信息
  338. * @returns {Promise<boolean>} 用户输入
  339. */
  340. confirm(msg) {
  341. const { label } = this.api.options
  342. return new Promise(resolve => {
  343. resolve(confirm(`${label ? `${label}\n\n` : ''}${msg}`))
  344. })
  345. }
  346.  
  347. /**
  348. * 创建基础输入对话框(异步)
  349. *
  350. * 若没有引入 `message` 模块,可使用 `api.message.prompt()` 引用该方法。
  351. * @param {string} msg 信息
  352. * @param {string} [val] 默认值
  353. * @returns {Promise<string>} 用户输入
  354. */
  355. prompt(msg, val) {
  356. const { label } = this.api.options
  357. return new Promise(resolve => {
  358. resolve(prompt(`${label ? `${label}\n\n` : ''}${msg}`, val))
  359. })
  360. }
  361. }(this)
  362.  
  363. /**
  364. * UserscriptAPILogger
  365. * @version 1.2.0.20210925
  366. */
  367. logger = new class UserscriptAPILogger {
  368. #logCss = `
  369. background-color: black;
  370. color: white;
  371. border-radius: 2px;
  372. padding: 2px;
  373. margin-right: 4px;
  374. `
  375.  
  376. /**
  377. * @param {UserscriptAPI} api `UserscriptAPI`
  378. */
  379. constructor(api) {
  380. this.api = api
  381. }
  382.  
  383. /**
  384. * 打印格式化日志
  385. * @param {'info' | 'warn' | 'error'} fn 日志函数名
  386. * @param {*[]} message 日志信息
  387. */
  388. #log(fn, ...message) {
  389. const output = console[fn]
  390. const label = this.api.options.label ?? ''
  391. const causes = []
  392. let template = null
  393. if (message.length > 0) {
  394. const types = []
  395. for (const [idx, m] of message.entries()) {
  396. if (m) {
  397. types.push(typeof m === 'string' ? '%s' : '%o')
  398. if (m instanceof Error && m.cause !== undefined) {
  399. causes.push(m.cause)
  400. }
  401. } else {
  402. if (m === undefined) {
  403. message[idx] = '[undefined]'
  404. } else if (m === null) {
  405. message[idx] = '[null]'
  406. } else if (m === '') {
  407. message[idx] = '[empty string]'
  408. }
  409. types.push(typeof message[idx] === 'string' ? '%s' : '%o')
  410. }
  411. }
  412. template = types.join(', ')
  413. } else {
  414. template = '[undefined]'
  415. }
  416. output(`%c${label}%c${template}`, this.#logCss, null, ...message)
  417. for (const [idx, cause] of causes.entries()) {
  418. output(`%c${label}%c${idx + 1}-th error is caused by %o`, this.#logCss, null, cause)
  419. }
  420. }
  421.  
  422. /**
  423. * 打印日志
  424. * @param {*[]} message 日志信息
  425. */
  426. info(...message) {
  427. this.#log('info', ...message)
  428. }
  429.  
  430. /**
  431. * 打印警告日志
  432. * @param {*[]} message 警告日志信息
  433. */
  434. warn(...message) {
  435. this.#log('warn', ...message)
  436. }
  437.  
  438. /**
  439. * 打印错误日志
  440. * @param {*[]} message 错误日志信息
  441. */
  442. error(...message) {
  443. this.#log('error', ...message)
  444. }
  445. }(this)
  446. }