91 Plus M

打造91譜的最佳體驗

安装此脚本
作者推荐脚本

您可能也喜欢91 Plus

安装此脚本
  1. // ==UserScript==
  2. // @name 91 Plus M
  3. // @namespace https://github.com/DonkeyBear
  4. // @version 1.0.2
  5. // @description 打造91譜的最佳體驗
  6. // @author DonkeyBear
  7. // @match *://www.91pu.com.tw/m/*
  8. // @match *://www.91pu.com.tw/song/*
  9. // @icon https://www.91pu.com.tw/icons/favicon-32x32.png
  10. // @antifeature tracking
  11. // @grant none
  12. // ==/UserScript==
  13.  
  14. /* global $ */
  15.  
  16. /** 若樂譜頁面為電腦版,跳轉至行動版 */
  17. function redirect () {
  18. const currentUrl = window.location.href;
  19. if ((/\/song\//).test(currentUrl)) {
  20. const sheetId = currentUrl.match(/(?<=\/)\d+(?=\.)/)[0];
  21. const newUrl = `https://www.91pu.com.tw/m/tone.shtml?id=${sheetId}`;
  22. window.location.replace(newUrl);
  23. }
  24. }
  25.  
  26. /** 引入 Google Analytics */
  27. function injectGtag () {
  28. const newScript = document.createElement('script');
  29. newScript.src = 'https://www.googletagmanager.com/gtag/js?id=G-JF4S3HZY31';
  30. newScript.async = true;
  31. document.head.appendChild(newScript);
  32. newScript.onload = () => {
  33. // 此區塊由 Google Analytics 生成
  34. window.dataLayer = window.dataLayer || [];
  35. function gtag () { window.dataLayer.push(arguments) }
  36. gtag('js', new Date());
  37. gtag('config', 'G-JF4S3HZY31');
  38. };
  39. }
  40.  
  41. /** 注入頁面樣式 */
  42. function injectStyle () {
  43. const stylesheet = /* css */`
  44. html {
  45. background: #fafafa url(/templets/pu/images/tone-bg.gif);
  46. }
  47.  
  48. header {
  49. background-color: rgba(25, 20, 90, 0.5);
  50. backdrop-filter: blur(5px) saturate(80%);
  51. -webkit-backdrop-filter: blur(5px) saturate(80%);
  52. display: flex;
  53. justify-content: center;
  54. font-family: system-ui;
  55. }
  56.  
  57. header > .set {
  58. width: 768px;
  59. }
  60.  
  61. .tfunc2 {
  62. margin: 10px;
  63. }
  64.  
  65. .setint {
  66. border-top: 1px solid rgba(255, 255, 255, 0.2);
  67. }
  68.  
  69. .setint,
  70. .plays .capo {
  71. display: flex;
  72. justify-content: space-between;
  73. }
  74.  
  75. #mtitle {
  76. font-family: system-ui;
  77. }
  78.  
  79. .setint {
  80. border-top: 0;
  81. padding: 10px;
  82. }
  83.  
  84. .setint > .hr {
  85. margin-right: 15px;
  86. padding: 0 15px;
  87. }
  88.  
  89. .capo-section {
  90. flex-grow: 1;
  91. margin-right: 0 !important;
  92. display: flex !important;
  93. justify-content: space-between !important;
  94. }
  95.  
  96. .capo-button.decrease {
  97. padding-right: 20px;
  98. }
  99.  
  100. .capo-button.increase {
  101. padding-left: 20px;
  102. }
  103.  
  104. /* 需要倒數才能關閉的蓋版廣告 */
  105. #viptoneWindow.window,
  106. /* 在頁面最底部的廣告 */
  107. #bottomad,
  108. /* 最上方提醒升級VIP的廣告 */
  109. .update_vip_bar,
  110. /* 譜上的LOGO和浮水印 */
  111. .wmask,
  112. /* 彈出式頁尾 */
  113. footer,
  114. /* 自動滾動頁面捲軸 */
  115. .autoscroll,
  116. /* 頁首的返回列 */
  117. .backplace,
  118. /* 頁首的多餘列 */
  119. .set .keys,
  120. .set .plays,
  121. .set .clear,
  122. /* 功能列上多餘的按鈕 */
  123. .setint .hr:nth-child(4),
  124. .setint .hr:nth-child(5),
  125. .setint .hr:nth-child(6),
  126. /* 其餘的Google廣告 */
  127. .adsbygoogle {
  128. display: none !important;
  129. }
  130. `;
  131. const style = document.createElement('style');
  132. style.innerText = stylesheet;
  133. document.head.appendChild(style);
  134. }
  135.  
  136. /**
  137. * @typedef {object} Params
  138. * @prop {number} transpose
  139. * @prop {boolean} darkMode
  140. */
  141. /**
  142. * 從 URL 取得參數
  143. * @returns {Params}
  144. */
  145. function getQueryParams () {
  146. const url = new URL(window.location.href);
  147. const params = {
  148. transpose: +url.searchParams.get('transpose'),
  149. darkMode: !!url.searchParams.get('darkmode')
  150. };
  151. return params;
  152. }
  153.  
  154. /** 用於操作和弦字串 */
  155. class Chord {
  156. static sharps = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
  157. static flats = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
  158.  
  159. /** @param {string} chordString */
  160. constructor (chordString) {
  161. this.chordString = chordString;
  162. }
  163.  
  164. /** @param {number} delta */
  165. transpose (delta) {
  166. this.chordString = this.chordString.replaceAll(/[A-G][#b]?/g, (note) => {
  167. const isSharp = Chord.sharps.includes(note);
  168. const scale = isSharp ? Chord.sharps : Chord.flats;
  169. const noteIndex = scale.indexOf(note);
  170. const transposedIndex = (noteIndex + delta + 12) % 12;
  171. const transposedNote = scale[transposedIndex];
  172. return transposedNote;
  173. });
  174. return this;
  175. }
  176.  
  177. switchModifier () {
  178. this.chordString = this.chordString.replaceAll(/[A-G][#b]/g, (note) => {
  179. const scale = note.includes('#') ? Chord.sharps : Chord.flats;
  180. const newScale = note.includes('#') ? Chord.flats : Chord.sharps;
  181. const noteIndex = scale.indexOf(note);
  182. return newScale[noteIndex];
  183. });
  184. return this;
  185. }
  186.  
  187. useSharpModifier () {
  188. this.chordString = this.chordString.replaceAll(/[A-G]b/g, (note) => {
  189. const noteIndex = Chord.flats.indexOf(note);
  190. return Chord.sharps[noteIndex];
  191. });
  192. return this;
  193. }
  194.  
  195. useFlatModifier () {
  196. this.chordString = this.chordString.replaceAll(/[A-G]#/g, (note) => {
  197. const noteIndex = Chord.sharps.indexOf(note);
  198. return Chord.flats[noteIndex];
  199. });
  200. return this;
  201. }
  202.  
  203. toString () {
  204. return this.chordString;
  205. }
  206.  
  207. toFormattedString () {
  208. return this.chordString.replaceAll(/[#b]/g, /* html */`<sup>$&</sup>`); // eslint-disable-line quotes
  209. }
  210. }
  211.  
  212. /** 用於修改樂譜 */
  213. class ChordSheetElement {
  214. /** @param {HTMLElement} chordSheetElement */
  215. constructor (chordSheetElement) {
  216. this.chordSheetElement = chordSheetElement;
  217. }
  218.  
  219. formatUnderlines () {
  220. const underlineEl = this.chordSheetElement.querySelectorAll('u');
  221. const doubleUnderlineEl = this.chordSheetElement.querySelectorAll('abbr');
  222. underlineEl.forEach((el) => { el.innerText = `{_${el.innerText}_}` });
  223. doubleUnderlineEl.forEach((el) => { el.innerText = `{=${el.innerText}=}` });
  224. return this;
  225. }
  226.  
  227. unformatUnderlines () {
  228. const underlineEl = this.chordSheetElement.querySelectorAll('u');
  229. const doubleUnderlineEl = this.chordSheetElement.querySelectorAll('abbr');
  230. const unformat = (nodeList) => {
  231. nodeList.forEach((el) => {
  232. el.innerHTML = el.innerText
  233. .replaceAll(/{_|{=|=}|_}/g, '')
  234. .replaceAll(/[a-zA-Z0-9#/]+/g, /* html */`<span class="tf">$&</span>`); // eslint-disable-line quotes
  235. });
  236. };
  237. unformat(underlineEl);
  238. unformat(doubleUnderlineEl);
  239. return this;
  240. }
  241. }
  242.  
  243. /** 用於取得樂譜相關資訊 */
  244. class ChordSheetDocument {
  245. constructor () {
  246. this.el = {
  247. mtitle: document.getElementById('mtitle'),
  248. tkinfo: document.querySelector('.tkinfo'),
  249. capoSelect: document.querySelector('.capo .select'),
  250. tinfo: document.querySelector('.tinfo'),
  251. tone_z: document.getElementById('tone_z')
  252. };
  253. }
  254.  
  255. getId () {
  256. const urlParams = new URLSearchParams(window.location.search);
  257. return Number(urlParams.get('id'));
  258. }
  259.  
  260. getTitle () {
  261. return this.el.mtitle.innerText.trim();
  262. }
  263.  
  264. getKey () {
  265. const match = this.el.tkinfo?.innerText.match(/(?<=原調:)\w*/);
  266. return match ? match[0].trim() : '';
  267. }
  268.  
  269. getPlay () {
  270. const match = this.el.capoSelect?.innerText.split(/\s*\/\s*/);
  271. return match ? match[1].trim() : '';
  272. }
  273.  
  274. getCapo () {
  275. const match = this.el.capoSelect?.innerText.split(/\s*\/\s*/);
  276. return match ? Number(match[0]) : 0;
  277. }
  278.  
  279. getSinger () {
  280. const match = this.el.tinfo?.innerText.match(/(?<=演唱:).*(?=\n|$)/);
  281. return match ? match[0].trim() : '';
  282. }
  283.  
  284. getComposer () {
  285. const match = this.el.tinfo?.innerText.match(/(?<=曲:).*?(?=詞:|$)/);
  286. return match ? match[0].trim() : '';
  287. }
  288.  
  289. getLyricist () {
  290. const match = this.el.tinfo?.innerText.match(/(?<=詞:).*?(?=曲:|$)/);
  291. return match ? match[0].trim() : '';
  292. }
  293.  
  294. getBpm () {
  295. const match = this.el.tkinfo?.innerText.match(/\d+/);
  296. return match ? Number(match[0]) : 0;
  297. }
  298.  
  299. getSheetText () {
  300. const formattedChordSheet = this.el.tone_z.innerText
  301. .replaceAll(/\s+?\n/g, '\n')
  302. .replaceAll('\n\n', '\n')
  303. .trim()
  304. .replaceAll(/\s+/g, (match) => { return `{%${match.length}%}` });
  305. return formattedChordSheet;
  306. }
  307. }
  308.  
  309. /**
  310. * 將 Header 和譜上的和弦移調,並實質修改於 DOM
  311. * @param {number} delta
  312. */
  313. function transposeSheet (delta) {
  314. // 修改 Header 上的 Capo
  315. const $spanCapo = $('.capo-button > .text-capo');
  316. const newSpanCapoText = (+$spanCapo.text() + delta) % 12;
  317. $spanCapo.text(newSpanCapoText);
  318.  
  319. // 修改 Header 上的 Key
  320. const $spanKey = $('.capo-button > .text-key');
  321. const keyName = new Chord($spanKey.text());
  322. const newSpanCapoHTML = keyName.transpose(-delta).toFormattedString();
  323. $spanKey.html(newSpanCapoHTML);
  324.  
  325. // 修改譜上的和弦
  326. $('#tone_z .tf').each(function () {
  327. const chord = new Chord($(this).text());
  328. const newChordHTML = chord.transpose(-delta).toFormattedString();
  329. $(this).html(newChordHTML);
  330. });
  331. };
  332.  
  333. /** 初始化並綁定大部分事件 */
  334. function initEventHandlers () {
  335. /** @type {number} */
  336. let originalCapo;
  337.  
  338. // 頁面動態讀取完成時觸發
  339. $('body').on('mutation.done', () => {
  340. // 記錄原調
  341. const $textCapo = $('.capo-button > .text-capo');
  342. originalCapo = +$textCapo.text();
  343.  
  344. // 依照 URL 參數進行移調
  345. if (getQueryParams().transpose) {
  346. transposeSheet(getQueryParams().transpose);
  347. }
  348. });
  349.  
  350. // 點擊移調按鈕時進行移調
  351. $('body').on('click', '.capo-section > .capo-button.decrease', () => { transposeSheet(-1) });
  352. $('body').on('click', '.capo-section > .capo-button.increase', () => { transposeSheet(1) });
  353. $('body').on('click', '.capo-section > .capo-button.info', () => {
  354. const $textCapo = $('.capo-button > .text-capo');
  355. const currentCapo = +$textCapo.text();
  356. transposeSheet(originalCapo - currentCapo);
  357. });
  358. }
  359.  
  360. /**
  361. * 將網頁標題替換為自訂格式
  362. * @returns {boolean} 是否完成
  363. */
  364. function changeTitle () {
  365. const $mtitle = $('#mtitle');
  366. const newTitle = $mtitle.text().trim();
  367. if (newTitle) {
  368. document.title = `${newTitle} | 91+ M`;
  369. return true;
  370. } else {
  371. return false;
  372. }
  373. }
  374.  
  375. /**
  376. * 修改 Header:替換移調按鈕、增加自訂按鈕等
  377. * @returns {boolean} 是否完成
  378. */
  379. function modifyHeader () {
  380. const capoSelectText = $('.capo .select').eq(0).text().trim();
  381. if (!capoSelectText) { return false }
  382.  
  383. const stringCapo = capoSelectText.split(/\s*\/\s*/)[0]; // CAPO
  384. const stringKey = capoSelectText.split(/\s*\/\s*/)[1]; // 調
  385.  
  386. // 新增功能鈕
  387. const newFunctionDiv = document.createElement('div');
  388. newFunctionDiv.classList.add('hr', 'capo-section');
  389. newFunctionDiv.innerHTML = /* html */`
  390. <button class="scf capo-button decrease">◀</button>
  391. <button class="scf capo-button info">
  392. CAPO:<span class="text-capo">${stringCapo}</span>
  393. (<span class="text-key">${
  394. stringKey.replaceAll(/[#b]/g, /* html */`<sup>$&</sup>`) // eslint-disable-line quotes
  395. }</span>)
  396. </button>
  397. <button class="scf capo-button increase">▶</button>
  398. `;
  399. document.querySelector('.setint').appendChild(newFunctionDiv);
  400.  
  401. return true;
  402. }
  403.  
  404. /**
  405. * 發送請求至 API,雲端備份樂譜
  406. * @returns {boolean} 是否完成
  407. */
  408. function archiveChordSheet () {
  409. const sheet = document.getElementById('tone_z');
  410. if (!sheet?.innerText.trim()) { return false }
  411.  
  412. const chordSheetDocument = new ChordSheetDocument();
  413. try {
  414. const chordSheetElement = new ChordSheetElement(sheet);
  415. chordSheetElement.formatUnderlines();
  416.  
  417. const formBody = {
  418. id: chordSheetDocument.getId(),
  419. title: chordSheetDocument.getTitle(),
  420. key: chordSheetDocument.getKey(),
  421. play: chordSheetDocument.getPlay(),
  422. capo: chordSheetDocument.getCapo(),
  423. singer: chordSheetDocument.getSinger(),
  424. composer: chordSheetDocument.getComposer(),
  425. lyricist: chordSheetDocument.getLyricist(),
  426. bpm: chordSheetDocument.getBpm(),
  427. sheet_text: chordSheetDocument.getSheetText()
  428. };
  429. chordSheetElement.unformatUnderlines();
  430.  
  431. fetch('https://91-plus-plus-api.fly.dev/archive', {
  432. method: 'POST',
  433. headers: {
  434. 'Content-Type': 'application/json'
  435. },
  436. body: JSON.stringify(formBody)
  437. })
  438. .then(response => { console.log('雲端樂譜備份成功:', response) })
  439. .catch(error => { console.error('雲端樂譜備份失敗:', error) });
  440. } catch {
  441. console.warn('樂譜解析失敗,無法備份');
  442. fetch(`https://91-plus-plus-api.fly.dev/report?id=${chordSheetDocument.getId()}`);
  443. }
  444.  
  445. return true;
  446. }
  447.  
  448. /**
  449. * @typedef {object} ObserverCheckList
  450. * @prop {boolean} changeTitle 是否已替換頁面標題
  451. * @prop {boolean} modifyHeader 是否已替換 Header
  452. * @prop {boolean} archiveChordSheet 是否已將樂譜進行雲端備份
  453. */
  454. /**
  455. * 透過 MutationObserver 觸發的處理函式
  456. * @param {ObserverCheckList} checkList
  457. */
  458. function observerHandler (checkList) {
  459. if (!checkList.changeTitle) {
  460. checkList.changeTitle = changeTitle();
  461. }
  462. if (!checkList.modifyHeader) {
  463. checkList.modifyHeader = modifyHeader();
  464. }
  465. if (!checkList.archiveChordSheet) {
  466. checkList.archiveChordSheet = archiveChordSheet();
  467. }
  468.  
  469. // 如果已全數完成,則觸發 body 上的 mutation.done 事件
  470. let isAllClear = true;
  471. for (const checked of Object.values(checkList)) {
  472. if (!checked) { isAllClear = false }
  473. }
  474. if (isAllClear) { $('body').trigger('mutation.done') }
  475. }
  476.  
  477. /** 初始化 MutationObserver */
  478. function initMutationObserver () {
  479. /** @type {ObserverCheckList} */
  480. const observerCheckList = {
  481. changeTitle: false,
  482. modifyHeader: false,
  483. archiveChordSheet: false
  484. };
  485.  
  486. const observer = new MutationObserver(() => {
  487. observerHandler(observerCheckList);
  488. });
  489. observer.observe(document.body, { childList: true, subtree: true });
  490. $('body').on('mutation.done', () => { observer.disconnect() });
  491. }
  492.  
  493. /** 於每天第一次使用時跳出升級建議 */
  494. function askToUpdate () {
  495. const currentUrl = window.location.href;
  496. if ((/\/song\//).test(currentUrl)) { return }
  497.  
  498. const storageKey = 'plus91-last-visit';
  499. const lastVisit = localStorage.getItem(storageKey);
  500. const formatDate = (date) => {
  501. const year = date.getFullYear();
  502. const month = date.getMonth() + 1;
  503. const day = date.getDate();
  504. return `${year}-${month}-${day}`;
  505. };
  506. const currentDate = formatDate(new Date());
  507. if (currentDate !== lastVisit) {
  508. localStorage.setItem(storageKey, currentDate);
  509. const ans = confirm('91 Plus M 已經停止更新和維護了,\n建議升級至全新版本的 91 Plus!\n\n(本訊息僅會在每天第一次使用時跳出)');
  510. if (ans) {
  511. window.location.replace('https://github.com/DonkeyBear/91Plus/wiki/91-Plus-%E8%88%87-91-Plus-M');
  512. }
  513. }
  514. }
  515.  
  516. /** 主程式進入點 */
  517. function main () {
  518. redirect();
  519. injectGtag();
  520. injectStyle();
  521. initEventHandlers();
  522. initMutationObserver();
  523. askToUpdate();
  524. }
  525.  
  526. main();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址