UserscriptAPI

My API for userscripts.

当前为 2021-08-30 提交的版本,查看 最新版本

此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.cn-greasyfork.org/scripts/409641/965576/UserscriptAPI.js

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