UserscriptAPI

My API for userscripts.

Ajankohdalta 12.7.2021. Katso uusin versio.

Tätä skriptiä ei tulisi asentaa suoraan. Se on kirjasto muita skriptejä varten sisällytettäväksi metadirektiivillä // @require https://update.greatest.deepsurf.us/scripts/409641/949671/UserscriptAPI.js.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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