lyricUtils v2

helpful utilities when working with song lyrics and OpenSong

  1. // ==UserScript==
  2. // @name lyricUtils v2
  3. // @namespace Violentmonkey Scripts
  4. // @include https://www.azlyrics.com/lyrics/*
  5. // @include https://www.letras.com/*
  6. // @include https://4334.sk/*
  7. // @include https://www.songlyrics.com/*
  8. // @include https://www.lyrics.com/track/*
  9. // @include https://www.lyrics.com/lyric-lf/*
  10. // @include https://bethelmusic.com/chords-and-lyrics/*
  11. // @include https://genius.com/*
  12. // @grant none
  13. // @version 2.7.2
  14. // @author KraXen72
  15. // @locale en-US
  16. // @license GPLv3
  17. // @description helpful utilities when working with song lyrics and OpenSong
  18. // ==/UserScript==
  19.  
  20. console.log("lyricUtils v2 loaded.")
  21.  
  22. let site = {
  23. n: "", // name
  24. querySelector: "", // where to get text from
  25. holderqs: "", // where to inject our versed div, other: overwrite, headonly
  26. textKey: "", // what to use to get text (for exampe innerText or textContent)
  27. advancedVerseSupport: false, // if the site gives info about which verse is chorus, which is bridge, etc
  28. verseHeadings: [], // if custom verse heading are provided (geinus, bethel, ...) they will be saved here
  29. parsedVerseHeadings: []
  30. }
  31.  
  32. // sites are expected to handle adding to site.verseHeadings themselves in either their extractor or in groupToVerses if absolutely neccessary
  33.  
  34. const TempText = document.createElement("textarea");
  35. const defaultElemStyleObj = {
  36. padding: ".5rem",
  37. border: "2px solid darkblue",
  38. borderRadius: "5px",
  39. margin: "0 .25rem",
  40. height: "40px",
  41. width: "40px",
  42. boxSizing: "border-box",
  43. }
  44.  
  45. function determineSite() {
  46. //console.log(window.location.hostname)
  47. switch (window.location.hostname) {
  48. case "4334.sk":
  49. site.n = "4334";
  50. site.holderqs = ".entry-content"
  51. break;
  52. case "www.letras.com":
  53. site.n = "letras";
  54. site.querySelector = ".cnt-letra.p402_premium"
  55. site.holderqs = "overwrite"
  56. site.textKey = "innerText"
  57. break;
  58. case "www.azlyrics.com":
  59. site.n = "azlyrics";
  60. site.querySelector = ".col-xs-12.col-lg-8.text-center div:not([class]):not([id])";
  61. site.holderqs = ".col-xs-12.col-lg-8.text-center"
  62. site.textKey = "textContent"
  63. break;
  64. case "www.songlyrics.com":
  65. site.n = "songlyrics"
  66. site.querySelector = "#songLyricsDiv"
  67. site.holderqs = "overwrite"
  68. site.textKey = "textContent"
  69. break;
  70. case "www.lyrics.com":
  71. site.n = "lyricscom"
  72. site.querySelector = "#lyric-body-text"
  73. site.holderqs = "overwrite"
  74. site.textKey = "innerText"
  75. break;
  76. case "bethelmusic.com":
  77. site.n = "bethel"
  78. site.holderqs = "#tabLyrics"
  79. site.textKey = "innerText"
  80. site.advancedVerseSupport = true // bethel has bold
  81. //TODO remove double/triple newlines
  82. break;
  83. case "genius.com":
  84. site.n = "genius"
  85. site.querySelector = 'div[data-lyrics-container="true"]'
  86. site.holderqs = "headonly"
  87. site.textKey = "innerText"
  88. site.advancedVerseSupport = true // genius has [Verse]
  89. break;
  90. default:
  91. console.error(`unknown site ${window.location.hostname}`);
  92. }
  93. }
  94.  
  95. function injectCSS(css) {
  96. const styleTag = document.createElement("style")
  97. styleTag.innerHTML = css
  98. document.head.appendChild(styleTag)
  99. }
  100.  
  101. function makeBtn(text, onclick) {
  102. const btn = document.createElement("button")
  103. btn.classList.add("lyricUtils-button")
  104. Object.assign(btn.style, defaultElemStyleObj)
  105. btn.onclick = onclick
  106. btn.textContent = text
  107. return btn
  108. }
  109.  
  110. function copyMe(textToCopy) {
  111. TempText.value = textToCopy;
  112. //TempText.style.display = "none"
  113. document.body.appendChild(TempText);
  114. TempText.select();
  115.  
  116. document.execCommand("copy");
  117. document.body.removeChild(TempText);
  118. console.log("copied stuff", textToCopy)
  119. }
  120.  
  121. // utility functions
  122.  
  123. const bare = (s) => s.trim().replaceAll(" ", "") // bare string: no whitespace, no spaces
  124.  
  125. const elem = (qs) => { // querySelector, but if qs is already an element just pass it on
  126. if (typeof qs === "string") return document.querySelector(qs)
  127. return qs
  128. }
  129.  
  130. const text = (qs, key) => elem(qs)[key] // elem + get text by key
  131. const textAll = (qs, key) => {
  132. if (typeof qs === "string") return [...document.querySelectorAll(qs)].map(elem => elem[key]).join("\n")
  133. return qs[key]
  134. }
  135.  
  136. // group plaintext into array of verses, depending on newlines & other context
  137. function groupToVerses(text) {
  138. const lines = text.split("\n")
  139. const newLines = [...lines]
  140. .filter(l => l !== "Hide Chords")
  141. .filter((line, i, arr) => { // remove multiple newlines after each other
  142. if (i > 0 && bare(line) === "" && bare(arr[i - 1]) === "") { return false } else { return true }
  143. })
  144.  
  145. console.log("debug newlines", newLines)
  146.  
  147. // first filter is to filter out Hide Chords, seconds is multiple newlines.
  148. // i tried to filter both in one pass but then "" "Hide Chords" "" doesen't get recognized as "" "" bc i shifts
  149.  
  150. let verses = [""] // starts with empty verse
  151.  
  152. for (let i = 0; i < newLines.length; i++) {
  153. const l = newLines[i]
  154. if (site.n === "genius" ? l.startsWith("[") && l.endsWith("]") : l === "" ) {
  155. if (site.n === "genius") site.verseHeadings.push(l)
  156. verses.push("") // start a new verse
  157. } else if (l.startsWith(" ")) { // if a thing like " (What?)" is on new line, after annotation.
  158. verses[verses.length - 1] = verses[verses.length - 1].slice(0, -4) + l + "<br>"
  159. } else {
  160. verses[verses.length - 1] += l + "<br>" // continue an existing verse
  161. }
  162. }
  163.  
  164. verses = verses.filter(v => v.replaceAll("<br>", "").trim() !== "") // filter out empty verses
  165. console.log("verses after filter:", verses)
  166. return verses
  167. }
  168. // FIXME genius still separates verses by multiple newlines
  169.  
  170. // get all text for copying
  171. function getTextContent() {
  172. let stuff = ""
  173. const newHolder = document.getElementById("lyricUtils-holder")
  174.  
  175. Array.from(newHolder.children).forEach(child => {
  176. if (site.n === "genius") {
  177. if (!(child.classList.contains("lyricUtils-button")) && child.id !== "lyricUtils-stateDiv") {
  178. child.innerHTML
  179. .split("<br>")
  180. .filter((item, i, arr) => item.trim() !== "" || i === arr.length - 1)
  181. .forEach((line, i, arr) => stuff += line.replaceAll("&amp;", "&") + "\n")
  182. }
  183. } else {
  184. if (!(child.classList.contains("lyricUtils-button")) && child.id !== "lyricUtils-stateDiv") stuff += child.innerText
  185. if (child.classList.contains("lyricUtils-vhead") || child.classList.contains("lyricUtils-verse")) stuff += "\n"
  186. }
  187. })
  188. console.log("tocopy", stuff)
  189. return stuff
  190. }
  191.  
  192. // transform headers in words like "Verse 1" into "[V1]"
  193. function ensureResolveOpensongHeaders() {
  194. if (site.parsedVerseHeadings.length > 0) return // already parsed
  195. if (site.verseHeadings.length === 0) return; // no support
  196. if (site.advancedVerseSupport === false) return; // no support
  197.  
  198. site.verseHeadings = site.verseHeadings.map(h => bare(h.replaceAll("[", "").replaceAll("]", "")))
  199.  
  200. const verseHeadingsCounter = { verse: 0, chorus: 0, bridge: 0 }
  201. const _opensongTag = ( tag = "V", type = "verse") => {
  202. // only one of this tag per song, no need for number (except verse)
  203. if (type !== "verse" && site.verseHeadings.filter(h => h.toLowerCase().startsWith(type)).length === 1) {
  204. verseHeadingsCounter[type] += 1
  205. return `${tag}`
  206. } else {
  207. verseHeadingsCounter[type] += 1
  208. return `${tag}${verseHeadingsCounter[type]}`
  209. }
  210. }
  211.  
  212. site.parsedVerseHeadings = site.verseHeadings
  213. .map((h, i) => {
  214. if (h.startsWith("Verse")) return _opensongTag("V", "verse")
  215. if (h.startsWith("Bridge")) return _opensongTag("B", "bridge")
  216. if (h.startsWith("Chorus")) return _opensongTag("C", "chorus")
  217. if (h.startsWith("Refrain")) return _opensongTag("C", "chorus")
  218.  
  219. verseHeadingsCounter.verse += 1
  220. return `V${i + 1}`
  221. })
  222. }
  223.  
  224. // function that either returns V1 - VXX, or if site.verseHeadings is defined, parse that and return more specific opensong headers
  225. function makeOpensongHeader(index) {
  226. if (!site.advancedVerseSupport || site.advancedVerseSupport && site.verseHeadings.length === 0) {
  227. return `V${index + 1}` }
  228. else {
  229. ensureResolveOpensongHeaders()
  230. return site.parsedVerseHeadings[index]
  231. }
  232. }
  233.  
  234. // make the actual div elements to host our text
  235. function constructVerseElement(verses) {
  236. const holder = document.createElement("div")
  237. holder.id = "lyricUtils-holder"
  238.  
  239. verses.forEach((v, index, arr) => {
  240. const vDiv = document.createElement("div")
  241. vDiv.id = `${makeOpensongHeader(index)}-lyricUtils`
  242. vDiv.classList.add("lyricUtils-verse")
  243. vDiv.innerHTML = v
  244. holder.appendChild(vDiv)
  245. if (index !== arr.length -1) holder.appendChild(document.createElement("br"))
  246. })
  247.  
  248. return holder
  249. }
  250.  
  251. function addHeaders() {
  252. if (document.querySelector(".lyricUtils-vhead") === null) {
  253. const allVerses = [...document.getElementsByClassName("lyricUtils-verse")]
  254. const newHolder = document.getElementById("lyricUtils-holder")
  255.  
  256. allVerses.forEach(verse => {
  257. const vHead = document.createElement("div")
  258. vHead.classList.add("lyricUtils-vhead")
  259. vHead.textContent = `[${verse.id.split("-")[0]}]` // VXX-lyricUtils => [VXX]
  260. newHolder.insertBefore(vHead, verse)
  261. })
  262. document.getElementById("lyricUtils-stateDiv").style.background = "lightgreen"
  263. } else {
  264. console.log("did nothing, headers are already added")
  265. }
  266. }
  267.  
  268. function removeHeaders() {
  269. [...document.getElementsByClassName("lyricUtils-vhead")].forEach(head => head.remove())
  270. document.getElementById("lyricUtils-stateDiv").style.background = ""
  271. }
  272.  
  273. // transparent modifier function
  274. function transModFn(value, debug = false) {
  275. if (debug) console.log(`fn-debug:`, value)
  276. return value
  277. }
  278.  
  279. const extractors = {
  280. // there are a bunch of mod function that can modify some part of the process, if needed
  281. // by default they are set to transModFn, which just passes through the value
  282. _standard: (qs, key, modTextFn = transModFn, modVerseFn = transModFn, modElemFn = transModFn) => {
  283. const rawText = modTextFn( text(qs, key) )
  284. const verses = modVerseFn( groupToVerses(rawText) )
  285. const ourElement = modElemFn( constructVerseElement(verses) )
  286.  
  287. if (site.holderqs === "overwrite") {
  288. elem(site.querySelector).innerHTML = ourElement.innerHTML
  289. elem(site.querySelector).id = "lyricUtils-holder" //has to go second, because qs can be an id selector
  290. } else if (site.holderqs === "headonly") {
  291. const holder = elem(site.querySelector).parentElement
  292. injectCSS(`.lyricUtils-verse { display: none; }`)
  293. holder.insertBefore(ourElement, elem(site.querySelector))
  294. } else {
  295. const holder = document.querySelector(site.holderqs)
  296. holder.insertBefore(ourElement, elem(site.querySelector).nextElementSibling)
  297. }
  298.  
  299. const stateElem = Object.assign(document.createElement("button"), { id: "lyricUtils-stateDiv", innerHTML: "🆚", title: "green = opensong verse headings [V1], [V2], ... are added"})
  300. Object.assign(stateElem.style, {...defaultElemStyleObj, cursor: "default" })
  301.  
  302. const newHolder = document.getElementById("lyricUtils-holder")
  303. newHolder.prepend(stateElem)
  304. newHolder.prepend(makeBtn("📋", () => { copyMe(getTextContent()) }))
  305. newHolder.prepend(makeBtn("⛔", removeHeaders))
  306. newHolder.prepend(makeBtn("➕", addHeaders))
  307. injectCSS(`.lyricUtils-button:hover { background-color: rgba(0,0,0,0.2) }`)
  308.  
  309. console.log("everything ran.", site.n, site.parsedVerseHeadings)
  310. },
  311. azlyrics: () => {
  312. injectCSS(`
  313. ${site.querySelector} { display: none !important; }
  314. .lyricUtils-button, #lyricUtils-stateDiv { margin-bottom: 1.2rem !important; }
  315. `)
  316. extractors._standard(site.querySelector, site.textKey)
  317. },
  318. letras: () => {
  319. injectCSS(`#player { display: none !important; } ::selection { background: #dcdc00 !important; }`);
  320. extractors._standard(site.querySelector, site.textKey)
  321. },
  322. 4334: () => {
  323. injectCSS(`
  324. div[style*="visibility: visible; position: absolute;"] { display: none !important; }
  325. .entry-content { display: flex; flex-direction: row-reverse; justify-content: space-between }
  326. .site-navigation-inner .searchform input, { width: 200px !important; padding-left: 10px !important; transition: all 0s !important; }
  327. .site-navigation-inner .searchform.active { margin-left: 0px !important }
  328. `)
  329.  
  330. // fix up stuff for copying: 4334 has chord divs in the text
  331. const clonedText = document.querySelector(".chordwp-container").cloneNode(true)
  332. const wrappers = [...clonedText.querySelectorAll(".chwp-lyrics-row-wrapper")]
  333. lines = []
  334.  
  335. wrappers.forEach(w => {
  336. const lyr = [...w.querySelectorAll(".chwp-lyrics")]
  337. let txt = []
  338. lyr.forEach(l => txt.push(l.textContent))
  339.  
  340. lines.push(`<div class="line">${txt.join("")}</div>`)
  341.  
  342. if (w.nextElementSibling && w.nextElementSibling.tagName === "BR") lines.push(`<div class="line"><br></div>`)
  343. })
  344. clonedText.innerHTML = lines.join("\n")
  345.  
  346. site.querySelector = clonedText
  347. site.textKey = "innerText"
  348.  
  349. extractors._standard(site.querySelector, site.textKey)
  350. },
  351. songlyrics: () => {
  352. injectCSS(`
  353. .iComment-popup { display: none !important; pointer-events: none !important; }
  354. #lyricUtils-holder { font-size: 16px; line-height: 1.6; }
  355. `)
  356. extractors._standard(site.querySelector, site.textKey)
  357. },
  358. lyricscom: () => {
  359. extractors._standard(site.querySelector, site.textKey)
  360. },
  361. genius: () => {
  362. injectCSS(`
  363. #lyricUtils-holder { grid-column: left-start / left-end; height: 2.1rem !important; min-height:0 !important; position: absolute; top: 12px; right: 0}
  364. .fdEmdh, .lyricUtils-vhead { display: none; }
  365. `)
  366. // overwrite the text getter to use querySelectorAll
  367. extractors._standard(site.querySelector, site.textKey, () => textAll(site.querySelector, site.textKey))
  368. },
  369. bethel: () => {
  370. injectCSS(`
  371. #tabLyrics { display: grid; grid-template: auto / max-content max-content auto; column-gap: 1rem; }
  372. #lyricUtils-holder { font-size: 16px; line-height: 20px; grid-colum: 2 / 3 }
  373. #tabLyrics .content { grid-colum: 1 / 2 }
  374. .nav.nav-tabs { display: none !important; poiner-events: none; }
  375. `)
  376.  
  377. const lyricsDiv = document.createElement("div")
  378. lyricsDiv.innerHTML = document.querySelector("#tabLyrics .content").innerHTML
  379. const bTags = [...lyricsDiv.querySelectorAll("p > b")]
  380. bTags.forEach(b => {
  381. site.verseHeadings.push(b.textContent)
  382. b.remove()
  383. })
  384.  
  385. site.querySelector = lyricsDiv
  386. extractors._standard(lyricsDiv, site.textKey)
  387. }
  388. }
  389.  
  390. console.log("lyricUtils v2 initialized.")
  391.  
  392. determineSite()
  393. extractors[site.n]()
  394.  
  395. console.log("lyricUtils v2 executed.")

QingJ © 2025

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