UserscriptAPI

My API for userscripts.

Tính đến 03-09-2021. Xem phiên bản mới nhất.

Script này sẽ không được không được cài đặt trực tiếp. Nó là một thư viện cho các script khác để bao gồm các chỉ thị meta // @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. }