您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds Spotify's What's New feature to the web version
// ==UserScript== // @name What's New for Web // @description Adds Spotify's What's New feature to the web version // @version 1.0.0 // @author j-weatherwax // @homepageURL https://github.com/j-weatherwax // @match https://open.spotify.com/* // @license MIT // @namespace https://github.com/j-weatherwax/whats-new-for-web // @grant none // ==/UserScript== (function() { 'use strict'; var mainCss = ` /**** What's New Button ****/ #sidebar { z-index: 9999; position: fixed; inset: 0px 0px auto auto; margin: 0px; transform: translate(-40px, 64px); top: 0; right: 0; padding: 20px; width: 400px; max-height: 70%; border: 5px solid black; background-color: #121212; overflow: auto; } `; var buttonHtml = ` <button class="Button-sc-1dqy6lx-0 fXEXug encore-over-media-set SFgYidQmrqrFEVh65Zrg" data-testid="user-widget-link" aria-label="What's New" data-encore-id="buttonTertiary" aria-expanded="false"> <figure class="tp8rO9vtqBGPLOhwcdYv" title="What's New" style="width: 32px; height: 32px;"> <div class="" style="width: 32px; height: 32px; inset-inline-start: 0px;"> <div class="KdxlBanhDJjzmHfqhP0X m95Ymx847hCaxHjmyXKX" data-testid="placeholder-wrapper"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-bell" viewBox="0 0 16 16"> <path d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2zM8 1.918l-.797.161A4.002 4.002 0 0 0 4 6c0 .628-.134 2.197-.459 3.742-.16.767-.376 1.566-.663 2.258h10.244c-.287-.692-.502-1.49-.663-2.258C12.134 8.197 12 6.628 12 6a4.002 4.002 0 0 0-3.203-3.92L8 1.917zM14.22 12c.223.447.481.801.78 1H1c.299-.199.557-.553.78-1C2.68 10.2 3 6.88 3 6c0-2.42 1.72-4.44 4.005-4.901a1 1 0 1 1 1.99 0A5.002 5.002 0 0 1 13 6c0 .88.32 4.2 1.22 6z"/> </svg> </div> </div> </figure> </button> `; // -------------------------- OATH ------------------------------- // // Cryptographic helper functions function generateRandomString(length) { let text = ''; let possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; for (let i = 0; i < length; i++) { text += possible.charAt(Math.floor(Math.random() * possible.length)); } return text; } async function generateCodeChallenge(codeVerifier) { function base64encode(string) { return btoa(String.fromCharCode.apply(null, new Uint8Array(string))) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); } const encoder = new TextEncoder(); const data = encoder.encode(codeVerifier); const digest = await window.crypto.subtle.digest('SHA-256', data); return base64encode(digest); } // ClientID can be public const clientId = 'cfeacce0918d4802964bffedd31b22b8'; const redirectUri = 'https://open.spotify.com/'; //Finish this later async function APIKeyInvalid(accessToken){ const response = await fetch('https://api.spotify.com/v1/me', { headers: { Authorization: 'Bearer ' + accessToken } }); const data = await response.json(); //console.log(data) if (response.status == 401) { return true; } return false; } //If token is valid, return token. If it is not valid, request a new token and return that async function getAndValidateAccessToken(){ const token = localStorage.getItem("access_token"); if (APIKeyInvalid(token)){ await requestNewAccessToken(); } return localStorage.getItem("access_token"); } async function requestNewAccessToken(){ await fetch('https://accounts.spotify.com/api/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', }, body: new URLSearchParams({ client_id: clientId, grant_type: 'refresh_token', refresh_token:localStorage.getItem("refresh_token"), }), }).then(response=>{ return response.json(); }) .then(data=>{ localStorage.setItem('access_token', data.access_token); localStorage.setItem('refresh_token', data.refresh_token); }) } if (localStorage.getItem('access_token') == null) { // get the "code" from the URL. // After allowing access, the URL will contain a code stored in a URL parameter called "code" // Using this code, we can send a POST request to get our access token. const urlParams = new URLSearchParams(window.location.search); let code = urlParams.get('code'); // 2 cases // case 1: User has just pressed "allow access", in this case the URL will contain the "code" parameter, // and we need to use it to fetch the access token. // case 2: User has previously granted access and we already generated and stored the access token in local storage. // In this case, we don't need to send a request to get the access token, instead we just read the token from local storage. if(code != null){ let codeVerif = localStorage.getItem('code_verifier'); let body = new URLSearchParams({ grant_type: 'authorization_code', code: code, redirect_uri: redirectUri, client_id: clientId, code_verifier: codeVerif }); const response = fetch('https://accounts.spotify.com/api/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body }) .then(response => { if (!response.ok) { throw new Error('HTTP status ' + response.status); } return response.json(); }) .then(data => { localStorage.setItem('access_token', data.access_token); localStorage.setItem('refresh_token', data.refresh_token); //console.log(data) // If user has just authenticated, move on to main logic Main(); }) .catch(error => { console.error('Error:', error); }); } else { // generate codeVerifier let codeVerifier = generateRandomString(128); // Generate CodeChallenge value, and open the spotify popup generateCodeChallenge(codeVerifier).then(codeChallenge => { let state = generateRandomString(16); let scope = 'user-read-private user-read-email user-follow-read'; localStorage.setItem('code_verifier', codeVerifier); let args = new URLSearchParams({ response_type: 'code', client_id: clientId, scope: scope, redirect_uri: redirectUri, state: state, code_challenge_method: 'S256', code_challenge: codeChallenge }); window.location = 'https://accounts.spotify.com/authorize?' + args; }); } } else { //If authenticated sometime in the past, move on to main logic function localStorage.removeItem('market'); Main(); } // function to get user's info async function getProfile() { let accessToken = await getAndValidateAccessToken(); const response = await fetch('https://api.spotify.com/v1/me', { headers: { Authorization: 'Bearer ' + accessToken } }); const data = await response.json(); localStorage.setItem('market', data.country); } // -------------------------- Logic ------------------------------- // // Fetch user's followed artists async function getFollowedArtists() { let accessToken = await getAndValidateAccessToken(); const response = await fetch('https://api.spotify.com/v1/me/following?type=artist', { headers: { Authorization: 'Bearer ' + accessToken } }) .then(response => { if (!response.ok) { throw new Error('HTTP status ' + response.status); } return response.json(); }) .then(data => { return data; }) .catch(error => { console.error('Error:', error); }); return response; } // Fetch artist's released from the last two months const fetchArtistReleases = async (artistId) => { let accessToken = localStorage.getItem('access_token'); let market = localStorage.getItem('market'); const date = new Date(); const twoMonthsAgo = new Date().setMonth(date.getMonth() - 2); const response = await fetch(`https://api.spotify.com/v1/artists/${artistId}/albums?include_groups=album,single&market=${market}&limit=50`, { headers: { 'Authorization': `Bearer ${accessToken}` } }); const data = await response.json(); const albums = data.items.filter(album => Date.parse(album.release_date) >= twoMonthsAgo); return albums; }; const fetchAlbums = async () => { const followedArtists = await getFollowedArtists(); const newAlbums = []; let artistList = Object.values(followedArtists)[0]; for (const i in artistList.items) { const artist = artistList.items[i]; const albums = await fetchArtistReleases(artist.id); newAlbums.push(...albums); } return newAlbums; }; function setDate(album){ const releaseDate = document.createElement('p'); releaseDate.classList.add('Type__TypeElement-sc-goli3j-0', 'ieTwfQ'); releaseDate.setAttribute("data-encore-id", "type"); const months = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ]; const today = new Date(); const release = new Date(album.release_date); const difference = Math.floor((today - release)/ (1000 * 3600 * 24)); if (difference < 1){ releaseDate.innerHTML = "Today"; } else if (difference == 1) { releaseDate.innerHTML = "1 day ago"; } else if (difference <= 7) { releaseDate.innerHTML = `${difference} days ago`; } else { releaseDate.innerHTML = months[release.getMonth()] + ' ' + release.getDate(); } return releaseDate; } function setArtists(album){ const tempDiv = document.createElement('span'); let counter = 1; for (const idx in album.artists){ const artist = album.artists[idx]; const artistInfo = document.createElement('a'); artistInfo.setAttribute("draggable", "true"); artistInfo.setAttribute("dir", "auto"); artistInfo.setAttribute("href", artist.external_urls.spotify); artistInfo.setAttribute("tabindex", "-1"); artistInfo.textContent = artist.name; tempDiv.appendChild(artistInfo) if (counter != album.artists.length){ const comma = document.createElement('a'); comma.style.textDecoration = "none"; comma.innerHTML = ", "; tempDiv.appendChild(comma); } counter += 1; } return tempDiv; } function render(data) { return fetchAlbums() .then(albums => { //sort the fetched releases by recency albums.sort((a,b) => (Date.parse(a.release_date) < Date.parse(b.release_date)) ? 1 : -1) const sidebarContainer = document.createElement('div') //make the html for each release to insert into the sidebar const albumDivs = albums.map(album => { const albumDiv = document.createElement('div'); var albumDivContainer = ` <div role="row sidebar-row" aria-rowindex="2" aria-selected="false"> <div data-testid="tracklist-row" class="h4HgbO_Uu1JYg5UGANeQ wTUruPetkKdWAR1dd6w4" draggable="true" role="presentation" style="height: 72px;"> <div class="gvLrgQXBFVW6m9MscfFA" role="gridcell" aria-colindex="2" tabindex="-1"> <div class="iCQtmPqY0QvkumAOuCjr sidebar-info-row"> <div class="CmkY1Ag0tJDfnFXbGgju n1EzbHQahSKztskTUAm3 album-art-div" aria-hidden="true" style="width: 100%; float: left; height: 64px;"> </div> <div class="release-info" style="width: 100%; float: left; margin: 0px 16px; "> <a draggable="false" class="t_yrXoUO3qGsJS4Y6iXX standalone-ellipsis-one-line" data-testid="internal-track-link" tabindex="-1"></a> <span class="artistInfoList Type__TypeElement-sc-goli3j-0 bDHxRN rq2VQ5mb9SDAFWbBIUIn standalone-ellipsis-one-line" data-encore-id="type"> </span> </div> </div> </div> </div> </div>`; albumDiv.innerHTML = albumDivContainer; albumDiv.style.padding = "2px 0px"; const sidebarInfoRow = albumDiv.querySelector('.iCQtmPqY0QvkumAOuCjr') const albumArtDiv = albumDiv.querySelector('.album-art-div') const albumImage = document.createElement('img'); albumImage.src = album.images[2].url; albumImage.alt = album.name; albumArtDiv.appendChild(albumImage); //albumImage.classList.add("mMx2LUixlnN_Fu45JpFB", "FqmFsMhuF4D0s35Z62Js", "Yn2Ei5QZn19gria6LjZj") const albumName = albumDiv.querySelector('.t_yrXoUO3qGsJS4Y6iXX') albumName.setAttribute("href", album.external_urls.spotify) albumName.textContent = album.name; const artistList = albumDiv.querySelector('.artistInfoList'); artistList.appendChild(setArtists(album)); //add release date for each album const release_info = albumDiv.querySelector('.release-info'); //release_info.style.height = "64px"; release_info.insertBefore(setDate(album), release_info.firstElementChild); //sidebarInfoRow.appendChild(albumName); return albumDiv; }); albumDivs.forEach(albumDiv => { sidebarContainer.appendChild(albumDiv); }) return sidebarContainer; }) .catch(error => { console.error(error); }); } // -------------------------- User interface ------------------------------- // function insertCss(css) { const style = document.createElement('style'); style.appendChild(document.createTextNode(css)); document.head.appendChild(style); return style; } async function createUI(buttonHtml) { let sidebarOpen = false const mainContainer = document.createElement('div'); mainContainer.innerHTML = buttonHtml; // Get the button element within the div const whatsnewBtn = mainContainer.querySelector('button'); whatsnewBtn.setAttribute('id','whats-new-btn'); whatsnewBtn.addEventListener('mouseover', function () { whatsnewBtn.setAttribute('data-context-menu-open', 'true'); }); whatsnewBtn.addEventListener('mouseout', function () { whatsnewBtn.removeAttribute('data-context-menu-open'); }); //Create the sidebar const sidebar = document.createElement('div'); sidebar.setAttribute('id','sidebar'); sidebar.classList.add('EZFyDnuQnx5hw78phLqP'); sidebar.style.display = 'none'; document.body.appendChild(sidebar); // Add content to the sidebar render() .then(result => { sidebar.appendChild(result); }) .catch(error => { console.error('Error:', error); }); function toggleSidebar() { if (sidebarOpen) { // Close the sidebar sidebar.style.display = 'none'; sidebarOpen = false; } else { // Open the sidebar sidebar.style.display = 'block'; sidebarOpen = true; } } whatsnewBtn.addEventListener('click', toggleSidebar); let header = document.getElementsByClassName("facDIsOQo9q7kiWc4jSg")[0]; header.insertBefore(mainContainer, header.lastElementChild); }; // -------------------------- Main ------------------------------- // //Creates button for sidebar. If header div for button does not exist yet, wait 100ms before trying again. function Init() { if (document.getElementsByClassName("facDIsOQo9q7kiWc4jSg")[0] != null){ createUI(buttonHtml); } else { setTimeout(() => {Init();}, 100); } } function Main() { insertCss(mainCss); Init(); getProfile(); } })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址