🏠 Home 

UserscriptAPI

My API for userscripts.

สคริปต์นี้ไม่ควรถูกติดตั้งโดยตรง มันเป็นคลังสำหรับสคริปต์อื่น ๆ เพื่อบรรจุด้วยคำสั่งเมทา // @require https://update.greasyfork.org/scripts/409641/1435266/UserscriptAPI.js

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