UserscriptAPI

My API for userscripts.

Ekde 2021/09/03. Vidu La ĝisdata versio.

This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.greatest.deepsurf.us/scripts/409641/967032/UserscriptAPI.js

  1. /* exported UserscriptAPI */
  2. /**
  3. * UserscriptAPI
  4. *
  5. * 根据使用到的功能,可能需要通过 `@grant` 引入 `GM_xmlhttpRequest` 或 `GM_download`。
  6. *
  7. * 如无特殊说明,涉及到时间时所用单位均为毫秒。
  8. * @version 1.7.1.20210903
  9. * @author Laster2800
  10. */
  11. class UserscriptAPI {
  12. /**
  13. * @param {Object} [options] 选项
  14. * @param {string} [options.id='default'] 标识符
  15. * @param {string} [options.label] 日志标签,为空时不设置标签
  16. * @param {Object} [options.wait] `wait` API 默认选项(默认值见构造器代码)
  17. * @param {Object} [options.wait.condition] `wait` 条件 API 默认选项
  18. * @param {Object} [options.wait.element] `wait` 元素 API 默认选项
  19. * @param {number} [options.fadeTime=400] UI 渐变时间
  20. */
  21. constructor(options) {
  22. this.options = {
  23. id: '_0',
  24. label: null,
  25. fadeTime: 400,
  26. ...options,
  27. wait: {
  28. condition: {
  29. callback: result => api.logger.info(result),
  30. interval: 100,
  31. timeout: 10000,
  32. onTimeout: function() {
  33. api.logger[this.stopOnTimeout ? 'error' : 'warn'](['TIMEOUT', 'executeAfterConditionPassed', options])
  34. },
  35. stopOnTimeout: true,
  36. stopCondition: null,
  37. onStop: () => api.logger.error(['STOP', 'executeAfterConditionPassed', options]),
  38. stopInterval: 50,
  39. stopTimeout: 0,
  40. onError: () => api.logger.error(['ERROR', 'executeAfterConditionPassed', options]),
  41. stopOnError: true,
  42. timePadding: 0,
  43. ...options?.wait?.condition,
  44. },
  45. element: {
  46. base: document,
  47. exclude: null,
  48. callback: el => api.logger.info(el),
  49. subtree: true,
  50. multiple: false,
  51. repeat: false,
  52. throttleWait: 100,
  53. timeout: 10000,
  54. onTimeout: function() {
  55. api.logger[this.stopOnTimeout ? 'error' : 'warn'](['TIMEOUT', 'executeAfterElementLoaded', options])
  56. },
  57. stopOnTimeout: false,
  58. stopCondition: null,
  59. onStop: () => api.logger.error(['STOP', 'executeAfterElementLoaded', options]),
  60. onError: () => api.logger.error(['ERROR', 'executeAfterElementLoaded', options]),
  61. stopOnError: true,
  62. timePadding: 0,
  63. ...options?.wait?.element,
  64. },
  65. },
  66. }
  67.  
  68. const win = typeof unsafeWindow == 'undefined' ? window : unsafeWindow
  69. /** @type {UserscriptAPI} */
  70. let api = win[`_userscriptAPI_${this.options.id}`]
  71. if (api) {
  72. api.options = this.options
  73. return api
  74. }
  75. api = win[`_userscriptAPI_${this.options.id}`] = this
  76.  
  77. const logCss = `
  78. background-color: black;
  79. color: white;
  80. border-radius: 2px;
  81. padding: 2px;
  82. margin-right: 2px;
  83. `
  84.  
  85. /** DOM 相关 */
  86. this.dom = {
  87. /**
  88. * 初始化 urlchange 事件
  89. * @see {@link https://stackoverflow.com/a/52809105 How to detect if URL has changed after hash in JavaScript}
  90. */
  91. initUrlchangeEvent() {
  92. if (!history._urlchangeEventInitialized) {
  93. const urlEvent = () => {
  94. const event = new Event('urlchange')
  95. // 添加属性,使其与 Tampermonkey urlchange 保持一致
  96. event.url = location.href
  97. return event
  98. }
  99. history.pushState = (f => function pushState() {
  100. const ret = f.apply(this, arguments)
  101. window.dispatchEvent(new Event('pushstate'))
  102. window.dispatchEvent(urlEvent())
  103. return ret
  104. })(history.pushState)
  105. history.replaceState = (f => function replaceState() {
  106. const ret = f.apply(this, arguments)
  107. window.dispatchEvent(new Event('replacestate'))
  108. window.dispatchEvent(urlEvent())
  109. return ret
  110. })(history.replaceState)
  111. window.addEventListener('popstate', () => {
  112. window.dispatchEvent(urlEvent())
  113. })
  114. history._urlchangeEventInitialized = true
  115. }
  116. },
  117.  
  118. /**
  119. * 添加样式
  120. * @param {string} css 样式
  121. * @param {HTMLDocument} [doc=document] 文档
  122. * @returns {HTMLStyleElement} `<style>`
  123. */
  124. addStyle(css, doc = document) {
  125. const style = doc.createElement('style')
  126. style.setAttribute('type', 'text/css')
  127. style.className = `${api.options.id}-style`
  128. style.appendChild(doc.createTextNode(css))
  129. const parent = doc.head || doc.documentElement
  130. if (parent) {
  131. parent.appendChild(style)
  132. } else { // 极端情况下会出现,DevTools 网络+CPU 双限制可模拟
  133. api.wait.waitForConditionPassed({
  134. condition: () => doc.head || doc.documentElement,
  135. timeout: 0,
  136. }).then(parent => parent.appendChild(style))
  137. }
  138. return style
  139. },
  140.  
  141. /**
  142. * 设定元素位置,默认设定为绝对居中
  143. *
  144. * 要求该元素此时可见且尺寸为确定值(一般要求为块状元素)。
  145. * @param {HTMLElement} target 目标元素
  146. * @param {Object} [config] 配置
  147. * @param {string} [config.position='fixed'] 定位方式
  148. * @param {string} [config.top='50%'] `style.top`
  149. * @param {string} [config.left='50%'] `style.left`
  150. */
  151. setPosition(target, config) {
  152. config = {
  153. position: 'fixed',
  154. top: '50%',
  155. left: '50%',
  156. ...config,
  157. }
  158. target.style.position = config.position
  159. const style = window.getComputedStyle(target)
  160. const top = (parseFloat(style.height) + parseFloat(style.paddingTop) + parseFloat(style.paddingBottom)) / 2
  161. const left = (parseFloat(style.width) + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)) / 2
  162. target.style.top = `calc(${config.top} - ${top}px)`
  163. target.style.left = `calc(${config.left} - ${left}px)`
  164. },
  165.  
  166. /**
  167. * 处理 HTML 元素的渐显和渐隐
  168. *
  169. * * 读取 `target` 上的 `fadeInDisplay` 来设定渐显开始后的 `display` 样式。若没有设定:
  170. * * 若当前 `display` 与 `fadeOutDisplay` 不同,默认值为当前 `display`。
  171. * * 若当前 `display` 与 `fadeOutDisplay` 相同,默认值为 `block`。
  172. *
  173. * * 读取 `target` 上的 `fadeOutDisplay` 来设定渐隐开始后的 `display` 样式,默认值为 `none`。
  174. *
  175. * * 读取 `target` 上的 `fadeInTime` 和 `fadeOutTime` 属性来设定渐显和渐隐时间,它们应为以 `ms` 为单位的 `number`;否则,`target.style.transition` 上关于时间的设定应该与 `api.options.fadeTime` 保持一致。
  176. *
  177. * * 读取 `target` 上的 `fadeInFunction` 和 `fadeOutFunction` 属性来设定渐变效果(默认 `ease-in-out`),它们应为符合 `transition-timing-function` 的 `string`。
  178. *
  179. * * 读取 `target` 上的 `fadeInNoInteractive` 和 `fadeOutNoInteractive` 属性来设定渐显和渐隐期间是否禁止交互,它们应为 `boolean`。
  180. * @param {boolean} inOut 渐显/渐隐
  181. * @param {HTMLElement} target HTML 元素
  182. * @param {() => void} [callback] 渐显/渐隐完成的回调函数
  183. */
  184. fade(inOut, target, callback) {
  185. // fadeId 等同于当前时间戳,其意义在于保证对于同一元素,后执行的操作必将覆盖前的操作
  186. let transitionChanged = false
  187. const fadeId = new Date().getTime()
  188. const fadeOutDisplay = target.fadeOutDisplay ?? 'none'
  189. target._fadeId = fadeId
  190. if (inOut) { // 渐显
  191. let displayChanged = false
  192. if (typeof target.fadeInTime == 'number' || target.fadeInFunction) {
  193. target.style.transition = `opacity ${target.fadeInTime ?? api.options.fadeTime}ms ${target.fadeInFunction ?? 'ease-in-out'}`
  194. transitionChanged = true
  195. }
  196. if (target.fadeInNoInteractive) {
  197. target.style.pointerEvents = 'none'
  198. }
  199. const originalDisplay = window.getComputedStyle(target).display
  200. let fadeInDisplay = target.fadeInDisplay
  201. if (!fadeInDisplay) {
  202. if (originalDisplay != fadeOutDisplay) {
  203. fadeInDisplay = originalDisplay
  204. } else {
  205. fadeInDisplay = 'block'
  206. }
  207. }
  208. if (originalDisplay != fadeInDisplay) {
  209. target.style.display = fadeInDisplay
  210. displayChanged = true
  211. }
  212. setTimeout(() => {
  213. let success = false
  214. if (target._fadeId <= fadeId) {
  215. target.style.opacity = '1'
  216. success = true
  217. }
  218. setTimeout(() => {
  219. callback?.(success)
  220. if (target._fadeId <= fadeId) {
  221. if (transitionChanged) {
  222. target.style.transition = ''
  223. }
  224. if (target.fadeInNoInteractive) {
  225. target.style.pointerEvents = ''
  226. }
  227. }
  228. }, target.fadeInTime ?? api.options.fadeTime)
  229. }, displayChanged ? 10 : 0) // 此处的 10ms 是为了保证修改 display 后在浏览器上真正生效;按 HTML5 定义,浏览器需保证 display 在修改后 4ms 内生效,但实际上大部分浏览器貌似做不到,等个 10ms 再修改 opacity
  230. } else { // 渐隐
  231. if (typeof target.fadeOutTime == 'number' || target.fadeOutFunction) {
  232. target.style.transition = `opacity ${target.fadeOutTime ?? api.options.fadeTime}ms ${target.fadeOutFunction ?? 'ease-in-out'}`
  233. transitionChanged = true
  234. }
  235. if (target.fadeOutNoInteractive) {
  236. target.style.pointerEvents = 'none'
  237. }
  238. target.style.opacity = '0'
  239. setTimeout(() => {
  240. let success = false
  241. if (target._fadeId <= fadeId) {
  242. target.style.display = fadeOutDisplay
  243. success = true
  244. }
  245. callback?.(success)
  246. if (success) {
  247. if (transitionChanged) {
  248. target.style.transition = ''
  249. }
  250. if (target.fadeOutNoInteractive) {
  251. target.style.pointerEvents = ''
  252. }
  253. }
  254. }, target.fadeOutTime ?? api.options.fadeTime)
  255. }
  256. },
  257.  
  258. /**
  259. * 为 HTML 元素添加 `class`
  260. * @param {HTMLElement} el 目标元素
  261. * @param {...string} className `class`
  262. */
  263. addClass(el, ...className) {
  264. el.classList?.add(...className)
  265. },
  266.  
  267. /**
  268. * 为 HTML 元素移除 `class`
  269. * @param {HTMLElement} el 目标元素
  270. * @param {...string} [className] `class`,未指定时移除所有 `class`
  271. */
  272. removeClass(el, ...className) {
  273. if (className.length > 0) {
  274. el.classList?.remove(...className)
  275. } else if (el.className) {
  276. el.className = ''
  277. }
  278. },
  279.  
  280. /**
  281. * 判断 HTML 元素类名中是否含有 `class`
  282. * @param {HTMLElement | {className: string}} el 目标元素
  283. * @param {string | string[]} className `class`,支持同时判断多个
  284. * @param {boolean} [and] 同时判断多个 `class` 时,默认采取 `OR` 逻辑,是否采用 `AND` 逻辑
  285. * @returns {boolean} 是否含有 `class`
  286. */
  287. containsClass(el, className, and = false) {
  288. const trim = clz => clz.startsWith('.') ? clz.slice(1) : clz
  289. if (el.classList) {
  290. if (className instanceof Array) {
  291. if (and) {
  292. for (const c of className) {
  293. if (!el.classList.contains(trim(c))) {
  294. return false
  295. }
  296. }
  297. return true
  298. } else {
  299. for (const c of className) {
  300. if (el.classList.contains(trim(c))) {
  301. return true
  302. }
  303. }
  304. return false
  305. }
  306. } else {
  307. return el.classList.contains(trim(className))
  308. }
  309. }
  310. return false
  311. },
  312.  
  313. /**
  314. * 判断 HTML 元素是否为 `fixed` 定位,或其是否在 `fixed` 定位的元素下
  315. * @param {HTMLElement} el 目标元素
  316. * @param {HTMLElement} [endEl] 终止元素,当搜索到该元素时终止判断(不会判断该元素)
  317. * @returns {boolean} HTML 元素是否为 `fixed` 定位,或其是否在 `fixed` 定位的元素下
  318. */
  319. isFixed(el, endEl) {
  320. while (el && el != endEl) {
  321. if (window.getComputedStyle(el).position == 'fixed') {
  322. return true
  323. }
  324. el = el.offsetParent
  325. }
  326. return false
  327. },
  328. }
  329. /** 信息通知相关 */
  330. this.message = {
  331. /**
  332. * 创建信息
  333. * @param {string} msg 信息
  334. * @param {Object} [config] 设置
  335. * @param {(msgbox: HTMLElement) => void} [config.onOpened] 信息打开后的回调
  336. * @param {(msgbox: HTMLElement) => void} [config.onClosed] 信息关闭后的回调
  337. * @param {boolean} [config.autoClose=true] 是否自动关闭信息,配合 `config.ms` 使用
  338. * @param {number} [config.ms=1500] 显示时间(单位:ms,不含渐显/渐隐时间)
  339. * @param {boolean} [config.html=false] 是否将 `msg` 理解为 HTML
  340. * @param {string} [config.width] 信息框的宽度,不设置的情况下根据内容决定,但有最小宽度和最大宽度的限制
  341. * @param {{top: string, left: string}} [config.position] 信息框的位置,不设置该项时,相当于设置为 `{ top: '70%', left: '50%' }`
  342. * @return {HTMLElement} 信息框元素
  343. */
  344. create(msg, config) {
  345. config = {
  346. autoClose: true,
  347. ms: 1500,
  348. html: false,
  349. width: null,
  350. position: {
  351. top: '70%',
  352. left: '50%',
  353. },
  354. ...config,
  355. }
  356.  
  357. const msgbox = document.createElement('div')
  358. msgbox.className = `${api.options.id}-msgbox`
  359. if (config.width) {
  360. msgbox.style.minWidth = 'auto' // 为什么一个是 auto 一个是 none?真是神奇的设计
  361. msgbox.style.maxWidth = 'none'
  362. msgbox.style.width = config.width
  363. }
  364. msgbox.style.display = 'block'
  365. if (config.html) {
  366. msgbox.innerHTML = msg
  367. } else {
  368. msgbox.textContent = msg
  369. }
  370. document.body.appendChild(msgbox)
  371. setTimeout(() => {
  372. api.dom.setPosition(msgbox, config.position)
  373. }, 10)
  374.  
  375. api.dom.fade(true, msgbox, () => {
  376. config.onOpened?.call(msgbox)
  377. if (config.autoClose) {
  378. setTimeout(() => {
  379. this.close(msgbox, config.onClosed)
  380. }, config.ms)
  381. }
  382. })
  383. return msgbox
  384. },
  385.  
  386. /**
  387. * 关闭信息
  388. * @param {HTMLElement} msgbox 信息框元素
  389. * @param {(msgbox: HTMLElement) => void} [callback] 信息关闭后的回调
  390. */
  391. close(msgbox, callback) {
  392. if (msgbox) {
  393. api.dom.fade(false, msgbox, () => {
  394. callback?.call(msgbox)
  395. msgbox?.remove()
  396. })
  397. }
  398. },
  399.  
  400. /**
  401. * 创建高级信息
  402. * @param {HTMLElement} el 启动元素
  403. * @param {string} msg 信息
  404. * @param {string} [flag] 标志信息
  405. * @param {Object} [config] 设置
  406. * @param {string} [config.flagSize='1.8em'] 标志大小
  407. * @param {string} [config.width] 信息框的宽度,不设置的情况下根据内容决定,但有最小宽度和最大宽度的限制
  408. * @param {{top: string, left: string}} [config.position] 信息框的位置,不设置该项时,沿用 `UserscriptAPI.message.create()` 的默认设置
  409. * @param {() => boolean} [config.disabled] 用于获取是否禁用信息的方法
  410. */
  411. advanced(el, msg, flag, config) {
  412. config = {
  413. flagSize: '1.8em',
  414. ...config
  415. }
  416.  
  417. const _self = this
  418. el.show = false
  419. el.addEventListener('mouseenter', function() {
  420. if (config.disabled?.()) return
  421. const htmlMsg = `
  422. <table class="gm-advanced-table"><tr>
  423. ${flag ? `<td style="font-size:${config.flagSize};line-height:${config.flagSize}">${flag}</td>` : ''}
  424. <td>${msg}</td>
  425. </tr></table>
  426. `
  427. this.msgbox = _self.create(htmlMsg, { ...config, html: true, autoClose: false })
  428.  
  429. let startPos = null // 鼠标进入预览时的初始坐标
  430. this.msgbox.addEventListener('mouseenter', function() {
  431. this.mouseOver = true
  432. })
  433. this.msgbox.addEventListener('mouseleave', function() {
  434. _self.close(this)
  435. })
  436. this.msgbox.addEventListener('mousemove', function(e) {
  437. if (startPos) {
  438. const dSquare = (startPos.x - e.clientX) ** 2 + (startPos.y - e.clientY) ** 2
  439. if (dSquare > 20 ** 2) { // 20px
  440. _self.close(this)
  441. }
  442. } else {
  443. startPos = {
  444. x: e.clientX,
  445. y: e.clientY,
  446. }
  447. }
  448. })
  449. })
  450. el.addEventListener('mouseleave', function() {
  451. setTimeout(() => {
  452. if (this.msgbox && !this.msgbox.mouseOver) {
  453. _self.close(this.msgbox)
  454. }
  455. }, 10)
  456. })
  457. },
  458.  
  459. /**
  460. * 创建提醒信息
  461. * @param {string} msg 信息
  462. */
  463. alert(msg) {
  464. alert(`${api.options.label ? `${api.options.label}\n\n` : ''}${msg}`)
  465. },
  466.  
  467. /**
  468. * 创建确认信息
  469. * @param {string} msg 信息
  470. * @returns {boolean} 用户输入
  471. */
  472. confirm(msg) {
  473. return confirm(`${api.options.label ? `${api.options.label}\n\n` : ''}${msg}`)
  474. },
  475.  
  476. /**
  477. * 创建输入提示信息
  478. * @param {string} msg 信息
  479. * @param {string} [val] 默认值
  480. * @returns {string} 用户输入
  481. */
  482. prompt(msg, val) {
  483. return prompt(`${api.options.label ? `${api.options.label}\n\n` : ''}${msg}`, val)
  484. },
  485. }
  486. /** 用于等待元素加载/条件达成再执行操作 */
  487. this.wait = {
  488. /**
  489. * 在条件达成后执行操作
  490. *
  491. * 当条件达成后,如果不存在终止条件,那么直接执行 `callback(result)`。
  492. *
  493. * 当条件达成后,如果存在终止条件,且 `stopTimeout` 大于 0,则还会在接下来的 `stopTimeout` 时间内判断是否达成终止条件,称为终止条件的二次判断。如果在此期间,终止条件通过,则表示依然不达成条件,故执行 `onStop()` 而非 `callback(result)`。如果在此期间,终止条件一直失败,则顺利通过检测,执行 `callback(result)`。
  494. *
  495. * @param {Object} options 选项;缺失选项用 `UserscriptAPI.options.wait.condition` 填充
  496. * @param {() => (* | Promise)} options.condition 条件,当 `condition()` 返回的 `result` 为真值时达成条件
  497. * @param {(result) => void} [options.callback] 当达成条件时执行 `callback(result)`
  498. * @param {number} [options.interval] 检测时间间隔
  499. * @param {number} [options.timeout] 检测超时时间,检测时间超过该值时终止检测;设置为 `0` 时永远不会超时
  500. * @param {() => void} [options.onTimeout] 检测超时时执行 `onTimeout()`
  501. * @param {boolean} [options.stopOnTimeout] 检测超时时是否终止检测
  502. * @param {() => (* | Promise)} [options.stopCondition] 终止条件,当 `stopCondition()` 返回的 `stopResult` 为真值时终止检测
  503. * @param {() => void} [options.onStop] 终止条件达成时执行 `onStop()`(包括终止条件的二次判断达成)
  504. * @param {number} [options.stopInterval] 终止条件二次判断期间的检测时间间隔
  505. * @param {number} [options.stopTimeout] 终止条件二次判断期间的检测超时时间,设置为 `0` 时禁用终止条件二次判断
  506. * @param {(e) => void} [options.onError] 条件检测过程中发生错误时执行 `onError()`
  507. * @param {boolean} [options.stopOnError] 条件检测过程中发生错误时,是否终止检测
  508. * @param {number} [options.timePadding] 等待 `timePadding`ms 后才开始执行;包含在 `timeout` 中,因此不能大于 `timeout`
  509. * @returns {() => boolean} 执行后终止检测的函数
  510. */
  511. executeAfterConditionPassed(options) {
  512. options = {
  513. ...api.options.wait.condition,
  514. ...options,
  515. }
  516. let stop = false
  517. let endTime = null
  518. if (options.timeout == 0) {
  519. endTime = 0
  520. } else {
  521. endTime = Math.max(new Date().getTime() + options.timeout - options.timePadding, 1)
  522. }
  523. const task = async () => {
  524. if (stop) return
  525. let result = null
  526. try {
  527. result = await options.condition()
  528. } catch (e) {
  529. options.onError?.(e)
  530. if (options.stopOnError) {
  531. stop = true
  532. }
  533. }
  534. if (stop) return
  535. const stopResult = await options.stopCondition?.()
  536. if (stopResult) {
  537. stop = true
  538. options.onStop?.()
  539. } else if (endTime !== 0 && new Date().getTime() > endTime) {
  540. if (options.stopOnTimeout) {
  541. stop = true
  542. } else {
  543. endTime = 0
  544. }
  545. options.onTimeout?.()
  546. } else if (result) {
  547. stop = true
  548. if (options.stopCondition && options.stopTimeout > 0) {
  549. this.executeAfterConditionPassed({
  550. condition: options.stopCondition,
  551. callback: options.onStop,
  552. interval: options.stopInterval,
  553. timeout: options.stopTimeout,
  554. onTimeout: () => options.callback(result)
  555. })
  556. } else {
  557. options.callback(result)
  558. }
  559. }
  560. if (!stop) {
  561. setTimeout(task, options.interval)
  562. }
  563. }
  564. setTimeout(async () => {
  565. if (stop) return
  566. await task()
  567. if (stop) return
  568. setTimeout(task, options.interval)
  569. }, options.timePadding)
  570. return function() {
  571. stop = true
  572. }
  573. },
  574.  
  575. /**
  576. * 在元素加载完成后执行操作
  577. *
  578. * ```plaintext
  579. * +────────────+──────────+───────────────────────────────────+
  580. * `multiple` | `repeat` | 说明
  581. * +────────────+──────────+───────────────────────────────────+
  582. * `false` | `false` | 查找第一个匹配元素,然后终止查找
  583. * `true` | `false` | 查找所有匹配元素,然后终止查找
  584. * `false` | `true` | 查找最后一个非标记匹配元素,并标记所有
  585. * | | 匹配元素,然后继续监听元素插入
  586. * `true` | `true` | 查找所有非标记匹配元素,并标记所有匹配
  587. * | | 元素,然后继续监听元素插入
  588. * +────────────+──────────+───────────────────────────────────+
  589. * ```
  590. *
  591. * @param {Object} options 选项;缺失选项用 `UserscriptAPI.options.wait.element` 填充
  592. * @param {string} options.selector 该选择器指定要等待加载的元素 `element`
  593. * @param {HTMLElement} [options.base] 基元素
  594. * @param {HTMLElement[]} [options.exclude] 若 `element` 在其中则跳过,并继续检测
  595. * @param {(element: HTMLElement) => void} [options.callback] 当 `element` 加载成功时执行 `callback(element)`
  596. * @param {boolean} [options.subtree] 是否将检测范围扩展为基元素的整棵子树
  597. * @param {boolean} [options.multiple] 若一次检测到多个目标元素,是否在所有元素上执行回调函数(否则只处理第一个结果)
  598. * @param {boolean} [options.repeat] `element` 加载成功后是否继续检测
  599. * @param {number} [options.throttleWait] 检测节流时间(非准确)
  600. * @param {number} [options.timeout] 检测超时时间,检测时间超过该值时终止检测;设置为 `0` 时永远不会超时
  601. * @param {() => void} [options.onTimeout] 检测超时时执行 `onTimeout()`
  602. * @param {boolean} [options.stopOnTimeout] 检测超时时是否终止检测
  603. * @param {() => (* | Promise)} [options.stopCondition] 终止条件,当 `stopCondition()` 返回的 `stopResult` 为真值时终止检测
  604. * @param {() => void} [options.onStop] 终止条件达成时执行 `onStop()`
  605. * @param {(e) => void} [options.onError] 检测过程中发生错误时执行 `onError()`
  606. * @param {boolean} [options.stopOnError] 检测过程中发生错误时,是否终止检测
  607. * @param {number} [options.timePadding] 等待 `timePadding`ms 后才开始执行;包含在 `timeout` 中,因此不能大于 `timeout`
  608. * @returns {() => boolean} 执行后终止检测的函数
  609. */
  610. executeAfterElementLoaded(options) {
  611. options = {
  612. ...api.options.wait.element,
  613. ...options,
  614. }
  615.  
  616. let loaded = false
  617. let stopped = false
  618. let tid = null // background timer id
  619.  
  620. let excluded = null
  621. if (options.exclude) {
  622. excluded = new WeakSet(options.exclude)
  623. } else if (options.repeat) {
  624. excluded = new WeakSet()
  625. }
  626. const valid = el => !(excluded?.has(el))
  627.  
  628. const stop = () => {
  629. if (!stopped) {
  630. stopped = true
  631. ob.disconnect()
  632. if (tid) {
  633. clearTimeout(tid)
  634. tid = null
  635. }
  636. }
  637. }
  638.  
  639. const singleTask = el => {
  640. let success = false
  641. try {
  642. if (valid(el)) {
  643. success = true // success 指查找成功,回调出错不影响
  644. options.repeat && excluded.add(el)
  645. options.callback(el)
  646. }
  647. } catch (e) {
  648. if (options.stopOnError) {
  649. throw e
  650. } else {
  651. options.onError?.(e)
  652. }
  653. }
  654. return success
  655. }
  656. const task = root => {
  657. let success = false
  658. if (options.multiple) {
  659. root.querySelectorAll(options.selector).forEach(el => {
  660. success = singleTask(el)
  661. })
  662. } else if (options.repeat) {
  663. const elements = root.querySelectorAll(options.selector)
  664. for (let i = elements.length - 1; i >= 0; i--) {
  665. const el = elements[i]
  666. if (success) {
  667. if (valid(el)) {
  668. excluded.add(el)
  669. }
  670. } else {
  671. success = singleTask(el)
  672. }
  673. }
  674. } else {
  675. const el = root.querySelector(options.selector)
  676. success = el && singleTask(el)
  677. }
  678. loaded = success || loaded
  679. if (loaded && !options.repeat) {
  680. stop()
  681. }
  682. return success
  683. }
  684. const throttledTask = options.throttleWait > 0 ? api.tool.throttle(task, options.throttleWait) : task
  685.  
  686. const ob = new MutationObserver(() => {
  687. if (stopped) return
  688. try {
  689. if (options.stopCondition?.()) {
  690. stop()
  691. options.onStop?.()
  692. return
  693. }
  694. throttledTask(options.base)
  695. } catch (e) {
  696. options.onError?.(e)
  697. if (options.stopOnError) {
  698. stop()
  699. }
  700. }
  701. })
  702.  
  703. setTimeout(() => {
  704. if (stopped) return
  705. try {
  706. if (options.stopCondition?.()) {
  707. stop()
  708. options.onStop?.()
  709. return
  710. }
  711. task(options.base)
  712. } catch (e) {
  713. options.onError?.(e)
  714. if (options.stopOnError) {
  715. stop()
  716. }
  717. }
  718. if (stopped) return
  719. ob.observe(options.base, {
  720. childList: true,
  721. subtree: options.subtree,
  722. })
  723. if (options.timeout > 0) {
  724. tid = setTimeout(() => {
  725. if (stopped) return
  726. tid = null
  727. if (!loaded) {
  728. if (options.stopOnTimeout) {
  729. stop()
  730. }
  731. options.onTimeout?.()
  732. } else { // 只要检测到,无论重复与否,都不算超时;需永久检测必须设 timeout 为 0
  733. stop()
  734. }
  735. }, Math.max(options.timeout - options.timePadding, 0))
  736. }
  737. }, options.timePadding)
  738. return stop
  739. },
  740.  
  741. /**
  742. * 等待条件达成
  743. *
  744. * 执行细节类似于 {@link executeAfterConditionPassed}。在原来执行 `callback(result)` 的地方执行 `resolve(result)`,被终止或超时执行 `reject()`。
  745. * @param {Object} options 选项;缺失选项用 `UserscriptAPI.options.wait.condition` 填充
  746. * @param {() => (* | Promise)} options.condition 条件,当 `condition()` 返回的 `result` 为真值时达成条件
  747. * @param {number} [options.interval] 检测时间间隔
  748. * @param {number} [options.timeout] 检测超时时间,检测时间超过该值时终止检测;设置为 `0` 时永远不会超时
  749. * @param {boolean} [options.stopOnTimeout] 检测超时时是否终止检测
  750. * @param {() => (* | Promise)} [options.stopCondition] 终止条件,当 `stopCondition()` 返回的 `stopResult` 为真值时终止检测
  751. * @param {number} [options.stopInterval] 终止条件二次判断期间的检测时间间隔
  752. * @param {number} [options.stopTimeout] 终止条件二次判断期间的检测超时时间,设置为 `0` 时禁用终止条件二次判断
  753. * @param {boolean} [options.stopOnError] 条件检测过程中发生错误时,是否终止检测
  754. * @param {number} [options.timePadding] 等待 `timePadding`ms 后才开始执行;包含在 `timeout` 中,因此不能大于 `timeout`
  755. * @returns {Promise} `result`
  756. * @throws 等待超时、达成终止条件、等待错误时抛出
  757. * @see executeAfterConditionPassed
  758. */
  759. async waitForConditionPassed(options) {
  760. return new Promise((resolve, reject) => {
  761. this.executeAfterConditionPassed({
  762. ...options,
  763. callback: result => resolve(result),
  764. onTimeout: function() {
  765. const error = ['TIMEOUT', 'waitForConditionPassed', this]
  766. if (this.stopOnTimeout) {
  767. reject(error)
  768. } else {
  769. api.logger.warn(error)
  770. }
  771. },
  772. onStop: function() {
  773. reject(['STOP', 'waitForConditionPassed', this])
  774. },
  775. onError: function(e) {
  776. reject(['ERROR', 'waitForConditionPassed', this, e])
  777. },
  778. })
  779. })
  780. },
  781.  
  782. /**
  783. * 等待元素加载完成
  784. *
  785. * 执行细节类似于 {@link executeAfterElementLoaded}。在原来执行 `callback(element)` 的地方执行 `resolve(element)`,被终止或超时执行 `reject()`。
  786. * @param {Object} options 选项;缺失选项用 `UserscriptAPI.options.wait.element` 填充
  787. * @param {string} options.selector 该选择器指定要等待加载的元素 `element`
  788. * @param {HTMLElement} [options.base] 基元素
  789. * @param {HTMLElement[]} [options.exclude] 若 `element` 在其中则跳过,并继续检测
  790. * @param {boolean} [options.subtree] 是否将检测范围扩展为基元素的整棵子树
  791. * @param {number} [options.throttleWait] 检测节流时间(非准确)
  792. * @param {number} [options.timeout] 检测超时时间,检测时间超过该值时终止检测;设置为 `0` 时永远不会超时
  793. * @param {() => (* | Promise)} [options.stopCondition] 终止条件,当 `stopCondition()` 返回的 `stopResult` 为真值时终止检测
  794. * @param {() => void} [options.onStop] 终止条件达成时执行 `onStop()`
  795. * @param {boolean} [options.stopOnTimeout] 检测超时时是否终止检测
  796. * @param {boolean} [options.stopOnError] 检测过程中发生错误时,是否终止检测
  797. * @param {number} [options.timePadding] 等待 `timePadding`ms 后才开始执行;包含在 `timeout` 中,因此不能大于 `timeout`
  798. * @returns {Promise<HTMLElement>} `element`
  799. * @throws 等待超时、达成终止条件、等待错误时抛出
  800. * @see executeAfterElementLoaded
  801. */
  802. async waitForElementLoaded(options) {
  803. return new Promise((resolve, reject) => {
  804. this.executeAfterElementLoaded({
  805. ...options,
  806. callback: element => resolve(element),
  807. onTimeout: function() {
  808. const error = ['TIMEOUT', 'waitForElementLoaded', this]
  809. if (this.stopOnTimeout) {
  810. reject(error)
  811. } else {
  812. api.logger.warn(error)
  813. }
  814. },
  815. onStop: function() {
  816. reject(['STOP', 'waitForElementLoaded', this])
  817. },
  818. onError: function() {
  819. reject(['ERROR', 'waitForElementLoaded', this])
  820. },
  821. })
  822. })
  823. },
  824.  
  825. /**
  826. * 元素加载选择器
  827. *
  828. * 执行细节类似于 {@link executeAfterElementLoaded}。在原来执行 `callback(element)` 的地方执行 `resolve(element)`,被终止或超时执行 `reject()`。
  829. * @param {string} selector 该选择器指定要等待加载的元素 `element`
  830. * @param {HTMLElement} [base=UserscriptAPI.options.wait.element.base] 基元素
  831. * @param {boolean} [stopOnTimeout=UserscriptAPI.options.wait.element.stopOnTimeout] 检测超时时是否终止检测
  832. * @returns {Promise<HTMLElement>} `element`
  833. * @throws 等待超时、达成终止条件、等待错误时抛出
  834. * @see executeAfterElementLoaded
  835. */
  836. async waitQuerySelector(selector, base = api.options.wait.element.base, stopOnTimeout = api.options.wait.element.stopOnTimeout) {
  837. return new Promise((resolve, reject) => {
  838. this.executeAfterElementLoaded({
  839. ...{ selector, base, stopOnTimeout },
  840. callback: element => resolve(element),
  841. onTimeout: function() {
  842. const error = ['TIMEOUT', 'waitQuerySelector', this]
  843. if (this.stopOnTimeout) {
  844. reject(error)
  845. } else {
  846. api.logger.warn(error)
  847. }
  848. },
  849. onStop: function() {
  850. reject(['STOP', 'waitQuerySelector', this])
  851. },
  852. onError: function() {
  853. reject(['ERROR', 'waitQuerySelector', this])
  854. },
  855. })
  856. })
  857. },
  858. }
  859. /** 网络相关 */
  860. this.web = {
  861. /** @typedef {Object} GM_xmlhttpRequest_details */
  862. /** @typedef {Object} GM_xmlhttpRequest_response */
  863. /**
  864. * 发起网络请求
  865. * @param {GM_xmlhttpRequest_details} details 定义及细节同 {@link GM_xmlhttpRequest} 的 `details`
  866. * @param {string | URLSearchParams | FormData} [details.data] 数据
  867. * @returns {Promise<GM_xmlhttpRequest_response>} 响应对象
  868. * @throws 等待超时、达成终止条件、等待错误时抛出
  869. * @see {@link https://www.tampermonkey.net/documentation.php#GM_xmlhttpRequest GM_xmlhttpRequest}
  870. */
  871. async request(details) {
  872. if (details) {
  873. return new Promise((resolve, reject) => {
  874. const throwHandler = function(msg) {
  875. api.logger.error('NETWORK REQUEST ERROR')
  876. reject(msg)
  877. }
  878. if (details.data && details.data instanceof URLSearchParams) {
  879. details.data = details.data.toString()
  880. details.headers = details.headers ?? { 'content-type': 'application/x-www-form-urlencoded' }
  881. }
  882. details.onerror = details.onerror ?? (() => throwHandler(['ERROR', 'request', details]))
  883. details.ontimeout = details.ontimeout ?? (() => throwHandler(['TIMEOUT', 'request', details]))
  884. details.onload = details.onload ?? (response => resolve(response))
  885. GM_xmlhttpRequest(details)
  886. })
  887. }
  888. },
  889.  
  890. /** @typedef {Object} GM_download_details */
  891. /**
  892. * 下载资源
  893. * @param {GM_download_details} details 定义及细节同 {@link GM_download} 的 `details`
  894. * @returns {() => void} 用于终止下载的方法
  895. * @see {@link https://www.tampermonkey.net/documentation.php#GM_download GM_download}
  896. */
  897. download(details) {
  898. if (details) {
  899. try {
  900. const cfg = { ...details }
  901. let name = cfg.name
  902. if (name.indexOf('.') >= 0) {
  903. let parts = cfg.url.split('/')
  904. const last = parts[parts.length - 1].split('?')[0]
  905. if (last.indexOf('.') >= 0) {
  906. parts = last.split('.')
  907. name = `${name}.${parts[parts.length - 1]}`
  908. } else {
  909. name = name.replaceAll('.', '_')
  910. }
  911. cfg.name = name
  912. }
  913. if (!cfg.onerror) {
  914. cfg.onerror = function(error, details) {
  915. api.logger.error('DOWNLOAD ERROR')
  916. api.logger.error([error, details])
  917. }
  918. }
  919. if (!cfg.ontimeout) {
  920. cfg.ontimeout = function() {
  921. api.logger.error('DOWNLOAD TIMEOUT')
  922. }
  923. }
  924. GM_download(cfg)
  925. } catch (e) {
  926. api.logger.error('DOWNLOAD ERROR')
  927. api.logger.error(e)
  928. }
  929. }
  930. return () => {}
  931. },
  932.  
  933. /**
  934. * 判断给定 URL 是否匹配
  935. * @param {RegExp | RegExp[]} reg 用于判断是否匹配的正则表达式,或正则表达式数组
  936. * @param {'SINGLE' | 'AND' | 'OR'} [mode='SINGLE'] 匹配模式
  937. * @returns {boolean} 是否匹配
  938. */
  939. urlMatch(reg, mode = 'SINGLE') {
  940. let result = false
  941. const href = location.href
  942. if (mode == 'SINGLE') {
  943. if (reg instanceof Array) {
  944. if (reg.length > 0) {
  945. reg = reg[0]
  946. } else {
  947. reg = null
  948. }
  949. }
  950. if (reg) {
  951. result = reg.test(href)
  952. }
  953. } else {
  954. if (!(reg instanceof Array)) {
  955. reg = [reg]
  956. }
  957. if (reg.length > 0) {
  958. if (mode == 'AND') {
  959. result = true
  960. for (const r of reg) {
  961. if (!r.test(href)) {
  962. result = false
  963. break
  964. }
  965. }
  966. } else if (mode == 'OR') {
  967. for (const r of reg) {
  968. if (r.test(href)) {
  969. result = true
  970. break
  971. }
  972. }
  973. }
  974. }
  975. }
  976. return result
  977. },
  978. }
  979. /**
  980. * 日志
  981. */
  982. this.logger = {
  983. /**
  984. * 打印格式化日志
  985. * @param {*} message 日志信息
  986. * @param {string} label 日志标签
  987. * @param {'info', 'warn', 'error'} [level] 日志等级
  988. */
  989. log(message, label, level = 'info') {
  990. const output = console[level == 'info' ? 'log' : level]
  991. const type = typeof message == 'string' ? '%s' : '%o'
  992. output(`%c${label}%c${type}`, logCss, '', message)
  993. },
  994.  
  995. /**
  996. * 打印日志
  997. * @param {*} message 日志信息
  998. */
  999. info(message) {
  1000. if (message === undefined) {
  1001. message = '[undefined]'
  1002. } else if (message === null) {
  1003. message = '[null]'
  1004. } else if (message === '') {
  1005. message = '[empty string]'
  1006. }
  1007. if (api.options.label) {
  1008. this.log(message, api.options.label)
  1009. } else {
  1010. console.log(message)
  1011. }
  1012. },
  1013.  
  1014. /**
  1015. * 打印警告日志
  1016. * @param {*} message 警告日志信息
  1017. */
  1018. warn(message) {
  1019. if (message === undefined) {
  1020. message = '[undefined]'
  1021. } else if (message === null) {
  1022. message = '[null]'
  1023. } else if (message === '') {
  1024. message = '[empty string]'
  1025. }
  1026. if (api.options.label) {
  1027. this.log(message, api.options.label, 'warn')
  1028. } else {
  1029. console.warn(message)
  1030. }
  1031. },
  1032.  
  1033. /**
  1034. * 打印错误日志
  1035. * @param {*} message 错误日志信息
  1036. */
  1037. error(message) {
  1038. if (message === undefined) {
  1039. message = '[undefined]'
  1040. } else if (message === null) {
  1041. message = '[null]'
  1042. } else if (message === '') {
  1043. message = '[empty string]'
  1044. }
  1045. if (api.options.label) {
  1046. this.log(message, api.options.label, 'error')
  1047. } else {
  1048. console.error(message)
  1049. }
  1050. },
  1051. }
  1052. /**
  1053. * 工具
  1054. */
  1055. this.tool = {
  1056. /**
  1057. * 生成消抖函数
  1058. * @param {Function} fn 目标函数
  1059. * @param {number} [wait=0] 消抖延迟
  1060. * @param {Object} [options] 选项
  1061. * @param {boolean} [options.leading] 是否在延迟开始前调用目标函数
  1062. * @param {boolean} [options.trailing=true] 是否在延迟结束后调用目标函数
  1063. * @param {number} [options.maxWait=0] 最大延迟时间(非准确),`0` 表示禁用
  1064. * @returns {Function} 消抖函数 `debounced`,可调用 `debounced.cancel()` 取消执行
  1065. */
  1066. debounce(fn, wait = 0, options = {}) {
  1067. options = {
  1068. leading: false,
  1069. trailing: true,
  1070. maxWait: 0,
  1071. ...options,
  1072. }
  1073.  
  1074. let tid = null
  1075. let start = null
  1076. let execute = null
  1077. let callback = null
  1078.  
  1079. function debounced() {
  1080. execute = () => {
  1081. fn.apply(this, arguments)
  1082. execute = null
  1083. }
  1084. callback = () => {
  1085. if (options.trailing) {
  1086. execute?.()
  1087. }
  1088. tid = null
  1089. start = null
  1090. }
  1091.  
  1092. if (tid) {
  1093. clearTimeout(tid)
  1094. if (options.maxWait > 0 && new Date().getTime() - start > options.maxWait) {
  1095. callback()
  1096. }
  1097. }
  1098.  
  1099. if (!tid && options.leading) {
  1100. execute?.()
  1101. }
  1102.  
  1103. if (!start) {
  1104. start = new Date().getTime()
  1105. }
  1106.  
  1107. tid = setTimeout(callback, wait)
  1108. }
  1109.  
  1110. debounced.cancel = function() {
  1111. if (tid) {
  1112. clearTimeout(tid)
  1113. tid = null
  1114. start = null
  1115. }
  1116. }
  1117.  
  1118. return debounced
  1119. },
  1120.  
  1121. /**
  1122. * 生成节流函数
  1123. * @param {Function} fn 目标函数
  1124. * @param {number} [wait=0] 节流延迟(非准确)
  1125. * @returns {Function} 节流函数 `throttled`,可调用 `throttled.cancel()` 取消执行
  1126. */
  1127. throttle(fn, wait = 0) {
  1128. return this.debounce(fn, wait, {
  1129. leading: true,
  1130. trailing: true,
  1131. maxWait: wait,
  1132. })
  1133. },
  1134. }
  1135.  
  1136. api.dom.addStyle(`
  1137. :root {
  1138. --${api.options.id}-light-text-color: white;
  1139. --${api.options.id}-shadow-color: #000000bf;
  1140. }
  1141.  
  1142. .${api.options.id}-msgbox {
  1143. z-index: 100000000;
  1144. background-color: var(--${api.options.id}-shadow-color);
  1145. font-size: 16px;
  1146. max-width: 24em;
  1147. min-width: 2em;
  1148. color: var(--${api.options.id}-light-text-color);
  1149. padding: 0.5em 1em;
  1150. border-radius: 0.6em;
  1151. opacity: 0;
  1152. transition: opacity ${api.options.fadeTime}ms ease-in-out;
  1153. user-select: none;
  1154. }
  1155.  
  1156. .${api.options.id}-msgbox .gm-advanced-table td {
  1157. vertical-align: middle;
  1158. }
  1159. .${api.options.id}-msgbox .gm-advanced-table td:first-child {
  1160. padding-right: 0.6em;
  1161. }
  1162. `)
  1163. }
  1164. }