osu beatmap filter

Filter beatmap by favorites (osu! website only)

  1. // ==UserScript==
  2. // @name osu beatmap filter
  3. // @name:zh-TW osu 圖譜過濾器
  4. // @namespace https://greasyfork.org/zh-TW/users/891293
  5. // @version 1.1.1
  6. // @description Filter beatmap by favorites (osu! website only)
  7. // @description:zh-TW 依照收藏數過濾 beatmap (僅限 osu! 網站)
  8. // @author Archer_Wn
  9. // @match https://osu.ppy.sh/*
  10. // @grant none
  11. // @license MIT
  12. // ==/UserScript==
  13.  
  14. // Options (選項)
  15. const options = {
  16. // global options (全域選項)
  17. global: {
  18. // enable animation (啟用動畫)
  19. animation: {
  20. // enable (啟用)
  21. enable: true,
  22. // duration [ms] (持續時間 [毫秒])
  23. duration: 700,
  24. },
  25. // filter method [threshold, percentile] (過濾方法 [門檻, 百分位數])
  26. filterMethod: "percentile",
  27. // compare method [less, lessEqual, greater, greaterEqual] (比較方法 [小於, 小於等於, 大於, 大於等於])
  28. compareMethod: "lessEqual",
  29. // opacity of filtered beatmap [0~1] (過濾後的 beatmap 透明度 [0~1])
  30. opacity: 0.15,
  31. },
  32. // favorites filter (收藏數過濾)
  33. favorites: {
  34. // threshold of favorite count (收藏數門檻)
  35. threshold: 100,
  36. // percentile of favorite count (收藏數百分位數)
  37. percentile: 75,
  38. },
  39. };
  40.  
  41. class BeatmapFilter {
  42. constructor() {
  43. this.beatmapItems = [];
  44. }
  45.  
  46. addBeatmapItem(beatmapItem) {
  47. const beatmapInfo = this._parseBeatmapItem(beatmapItem);
  48. this.beatmapItems.push({ beatmapItem, beatmapInfo });
  49.  
  50. this._filter();
  51. }
  52.  
  53. removeBeatmapItem(beatmapItem) {
  54. const index = this.beatmapItems.findIndex(
  55. (item) => item.beatmapItem === beatmapItem
  56. );
  57. if (index === -1) return;
  58.  
  59. this.beatmapItems.splice(index, 1);
  60.  
  61. this._filter();
  62. }
  63.  
  64. _filter() {
  65. if (options.global.animation.enable) {
  66. for (const { beatmapItem } of this.beatmapItems) {
  67. beatmapItem.style.transition = `opacity ${options.global.animation.duration}ms`;
  68. }
  69. }
  70.  
  71. switch (options.global.filterMethod) {
  72. case "threshold":
  73. this._filterByThreshold();
  74. break;
  75. case "percentile":
  76. this._filterByPercentile();
  77. break;
  78. }
  79. }
  80.  
  81. _filterByThreshold() {
  82. for (const { beatmapItem, beatmapInfo } of this.beatmapItems) {
  83. switch (options.global.compareMethod) {
  84. case "less":
  85. beatmapItem.style.opacity =
  86. beatmapInfo.favoriteCount < options.favorites.threshold
  87. ? options.global.opacity
  88. : 1;
  89. break;
  90. case "lessEqual":
  91. beatmapItem.style.opacity =
  92. beatmapInfo.favoriteCount <= options.favorites.threshold
  93. ? options.global.opacity
  94. : 1;
  95. break;
  96. case "greater":
  97. beatmapItem.style.opacity =
  98. beatmapInfo.favoriteCount > options.favorites.threshold
  99. ? options.global.opacity
  100. : 1;
  101. break;
  102. case "greaterEqual":
  103. beatmapItem.style.opacity =
  104. beatmapInfo.favoriteCount >= options.favorites.threshold
  105. ? options.global.opacity
  106. : 1;
  107. break;
  108. }
  109. }
  110. }
  111.  
  112. _filterByPercentile() {
  113. const favoriteCounts = this.beatmapItems
  114. .map((item) => item.beatmapInfo.favoriteCount)
  115. .sort((a, b) => a - b);
  116.  
  117. const threshold =
  118. favoriteCounts[
  119. Math.floor(
  120. (options.favorites.percentile / 100) * (favoriteCounts.length - 1)
  121. )
  122. ];
  123.  
  124. for (const { beatmapItem, beatmapInfo } of this.beatmapItems) {
  125. switch (options.global.compareMethod) {
  126. case "less":
  127. beatmapItem.style.opacity =
  128. beatmapInfo.favoriteCount < threshold ? options.global.opacity : 1;
  129. break;
  130. case "lessEqual":
  131. beatmapItem.style.opacity =
  132. beatmapInfo.favoriteCount <= threshold ? options.global.opacity : 1;
  133. break;
  134. case "greater":
  135. beatmapItem.style.opacity =
  136. beatmapInfo.favoriteCount > threshold ? options.global.opacity : 1;
  137. break;
  138. case "greaterEqual":
  139. beatmapItem.style.opacity =
  140. beatmapInfo.favoriteCount >= threshold ? options.global.opacity : 1;
  141. break;
  142. }
  143. }
  144. }
  145.  
  146. _convertToNumber(str) {
  147. const unitMap = {
  148. K: 1000,
  149. M: 1000000,
  150. 萬: 10000,
  151. 億: 100000000,
  152. };
  153.  
  154. const regex = new RegExp(`([0-9.]+)(${Object.keys(unitMap).join("|")})?`);
  155. const result = regex.exec(str);
  156. if (!result) return 0;
  157.  
  158. const number = parseFloat(result[1]);
  159. const unit = result[2];
  160. return number * (unit ? unitMap[unit] : 1);
  161. }
  162.  
  163. _parseBeatmapItem(beatmapItem) {
  164. const beatmapInfo = {};
  165.  
  166. // Extract necessary information
  167. beatmapInfo.favoriteCount = this._convertToNumber(
  168. beatmapItem.querySelectorAll(
  169. ".beatmapset-panel__stats-item--favourite-count span"
  170. )[1].textContent
  171. );
  172.  
  173. return beatmapInfo;
  174. }
  175. }
  176.  
  177. (function () {
  178. "use strict";
  179.  
  180. // define BeatmapFilter
  181. let beatmapFilter;
  182.  
  183. // if beatmapList loaded, start main function
  184. new MutationObserver((mutations) => {
  185. mutations.forEach((mutation) => {
  186. if (mutation.addedNodes.length < 1) return;
  187.  
  188. for (const node of mutation.addedNodes) {
  189. if (!node.querySelectorAll) return;
  190. const divs = node.querySelectorAll("div");
  191. for (const div of divs) {
  192. if (div.classList.contains("beatmapsets__items")) {
  193.  
  194. // create beatmap filter
  195. beatmapFilter = new BeatmapFilter();
  196.  
  197. // start main function
  198. main();
  199. return;
  200. }
  201. }
  202. }
  203. });
  204. }).observe(document, {
  205. childList: true,
  206. subtree: true,
  207. });
  208.  
  209. // main function
  210. function main() {
  211. // get beatmap list
  212. const beatmapList = document.querySelector(".beatmapsets__items");
  213. if (!beatmapList) {
  214. console.error("beatmapList not found");
  215. return;
  216. }
  217.  
  218. // handle beatmap items that already loaded
  219. const beatmapItems = beatmapList.querySelectorAll(".beatmapsets__item");
  220. for (const beatmapItem of beatmapItems) {
  221. beatmapFilter.addBeatmapItem(beatmapItem);
  222. }
  223.  
  224. // observe beatmap list
  225. new MutationObserver((mutations) => {
  226. mutations.forEach((mutation) => {
  227. if (mutation.type !== "childList") return;
  228.  
  229. // node added
  230. if (mutation.addedNodes.length >= 1) {
  231. for (const beatmapItem of mutation.addedNodes[0].querySelectorAll(
  232. ".beatmapsets__item"
  233. )) {
  234. beatmapFilter.addBeatmapItem(beatmapItem);
  235. }
  236. }
  237.  
  238. // node removed
  239. if (mutation.removedNodes.length >= 1) {
  240. for (const beatmapItem of mutation.removedNodes[0].querySelectorAll(
  241. ".beatmapsets__item"
  242. )) {
  243. beatmapFilter.removeBeatmapItem(beatmapItem);
  244. }
  245. }
  246. });
  247. }).observe(beatmapList, {
  248. attributes: true,
  249. childList: true,
  250. subtree: true,
  251. });
  252. }
  253. })();