Greasy Fork 支持简体中文。

喜马拉雅专辑下载器

XMLY Downloader

// ==UserScript==
// @name            喜马拉雅专辑下载器
// @version         1.3.4
// @description     XMLY Downloader
// @author          B-Y-F
// @match           *://www.ximalaya.com/*
// @grant           GM_download
// @icon            https://www.ximalaya.com/favicon.ico
// @require         https://registry.npmmirror.com/crypto-js/4.1.1/files/crypto-js.js
// @license         MIT
// @namespace https://greasyfork.org/users/323093
// ==/UserScript==

async function fetchUntilSuccess(url) {
  while (true) {
    try {
      const response = await fetch(url);
      if (response.ok) {
        const data = await response.json();
        return data;
      }
      console.error(
        `Failed to fetch: ${response.status} ${response.statusText}`
      );
    } catch (error) {
      console.error(`Fetch error: ${error.message}`);
    }
    await new Promise((resolve) => setTimeout(resolve, 1000));
  }
}

function extractTrackUrl(tracks) {
  let timestamp = Date.now();
  return Array.from(tracks).map((t, index) => {
    timestamp += 5 * 60 * 1000 * index;
    const trackID = t.trackId;
    const title = t.title;
    const url = `https://www.ximalaya.com/mobile-playpage/track/v3/baseInfo/${timestamp}?device=web&trackId=${trackID}`;
    return { title, url };
  });
}

async function getAllTrackIds() {
  function getAlbumId() {
    const match = window.location.href.match(/.*\/(\d+)/);
    return match ? match[1] : null;
  }

  async function getTotalTrackCount() {
    const albumId = getAlbumId();
    const apiUrl = `https://www.ximalaya.com/tdk-web/seo/search/albumInfo?albumId=${albumId}`;
    const data = await fetchUntilSuccess(apiUrl);
    return data.data.trackCount;
  }

  async function fetchTracks(pages) {
    let tracks = [];

    for (let index = 0; index < pages; index++) {
      await new Promise((resolve) => setTimeout(resolve, 2000));
      document.querySelectorAll(".sound-list li").forEach((li) => {
        if (li.classList.contains("_nO")) {
          // Assuming the child anchor tag holds the title and URL
          const anchor = li.querySelector("a");
          if (anchor) {
            const title = anchor.getAttribute("title");
            const trackId = anchor.getAttribute("href").split("/").pop();
            tracks.push({ title, trackId });
          } else {
            console.log("No anchor tag found in li with class '_nO'");
          }
        }
      });
      // Only execute part2 for the first n-1 iterations
      if (index < pages - 1) {
        const nextPageButton = document.querySelector(
          "li.page-next a.page-link"
        );
        nextPageButton.click();
      }
    }

    return tracks;
  }

  const totalTrackCount = await getTotalTrackCount();
  const pages = Math.ceil(totalTrackCount / 30);
  const tracks = await fetchTracks(pages);
  console.log("raw tracks", tracks);
  return extractTrackUrl(tracks);
}

function decrypt(t) {
  return CryptoJS.AES.decrypt(
    {
      ciphertext: CryptoJS.enc.Base64url.parse(t),
    },
    CryptoJS.enc.Hex.parse("aaad3e4fd540b0f79dca95606e72bf93"),
    {
      mode: CryptoJS.mode.ECB,
      padding: CryptoJS.pad.Pkcs7,
    }
  ).toString(CryptoJS.enc.Utf8);
}

async function fetchUrl(apiUrl) {
  try {
    const data = await fetchUntilSuccess(apiUrl);
    if (data.ret === 1001) {
      throw new Error(
        "Rate limited!!! Wait for a while then download again..."
      );
    }
    const bestAudioUrl = data.trackInfo.playUrlList[0].url;
    return bestAudioUrl;
  } catch (error) {
    console.error("Error fetching the URL:", error);
    throw error;
  }
}

async function getTrueUrl(title, url) {
  try {
    const fetchedUrl = await fetchUrl(url);
    const trueUrl = decrypt(fetchedUrl);
    const fileName = `${title}.m4a`;
    return { fileName, trueUrl };
  } catch (error) {
    console.error("Error getting the true url:", error);
    throw error;
  }
}

function initializeUI() {
  const progressDisplay = document.createElement("div");
  progressDisplay.style.position = "fixed";
  progressDisplay.style.bottom = "50px";
  progressDisplay.style.right = "10px";
  progressDisplay.style.zIndex = 1000;
  progressDisplay.style.backgroundColor = "white";
  progressDisplay.style.padding = "10px";
  progressDisplay.style.border = "1px solid black";
  progressDisplay.style.display = "none"; // Initially hidden
  document.body.appendChild(progressDisplay);

  // Create a container div
  const container = document.createElement("div");
  container.style.position = "fixed";
  container.style.bottom = "10px";
  container.style.right = "10px";
  container.style.zIndex = 1000;
  container.style.display = "flex";
  container.style.alignItems = "center";
  document.body.appendChild(container);

  const button = document.createElement("button");
  button.textContent = "解析ID";
  button.style.marginLeft = "10px";
  container.appendChild(button);

  button.addEventListener("click", async function parseIds() {
    progressDisplay.style.display = "block";
    progressDisplay.textContent = "ID解析进行中...";
    const tracks = await getAllTrackIds();
    progressDisplay.textContent = "ID解析完成";
    button.textContent = "解析URL";

    button.removeEventListener("click", parseIds);
    button.addEventListener("click", async function parseUrls() {
      progressDisplay.textContent = "URL解析进行中...";
      let finalDownloadList = [];
      for (let index = 0; index < tracks.length; index++) {
        const t = tracks[index];
        const item = await getTrueUrl(t.title, t.url, index);
        finalDownloadList.push(item);
        progressDisplay.textContent = `解析进程: ${index} / ${tracks.length}`;
      }

      console.log(finalDownloadList);

      if (finalDownloadList.length > 0) {
        progressDisplay.textContent = "URL解析完成。";
        button.textContent = "下载";

        // Create the checkbox
        const label = document.createElement("label");
        label.htmlFor = "sequenceOrder";
        label.textContent = "加序号";
        label.style.marginLeft = "5px";
        label.style.backgroundColor = "white";
        container.appendChild(label);
        const seqNumberCheckbox = document.createElement("input");
        seqNumberCheckbox.type = "checkbox";
        container.appendChild(seqNumberCheckbox);
        let isSequenceOrder = seqNumberCheckbox.checked;
        seqNumberCheckbox.addEventListener("change", () => {
          isSequenceOrder = seqNumberCheckbox.checked;
        });

        button.removeEventListener("click", parseUrls);
        button.addEventListener("click", function downloadFiles() {
          let count = 0;
          progressDisplay.textContent = `下载进程: ${count} / ${tracks.length}`;
          finalDownloadList.forEach((item, index) => {
            GM_download({
              url: item.trueUrl,
              name: isSequenceOrder
                ? `${index}.${item.fileName}`
                : `${item.fileName}`,
              onerror: function (error) {
                console.error("Error downloading " + item.fileName, error);
              },
              ontimeout: function () {
                console.error("Timeout downloading " + item.fileName);
              },
              onload: function () {
                console.log("Successfully downloaded " + item.fileName);
                count++;
                progressDisplay.textContent = `Downloaded ${count} / ${tracks.length}`;
              },
            });
          });
        });
      } else {
        progressDisplay.textContent = "URL解析失败,请重试";
      }
    });
  });
}

initializeUI();