Greasy Fork

Greasy Fork is available in English.

喜马拉雅音频地址提取工具 - 12redcircle

提取喜马拉雅网页上专辑和音频的播放链接

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name                喜马拉雅音频地址提取工具 - 12redcircle
// @namespace           cyou.12redcircle.xmly-radio-extractor
// @match               https://www.ximalaya.com/**
// @require             https://cdn.jsdelivr.net/npm/[email protected]/js/md5.min.js
// @require             https://cdn.jsdelivr.net/npm/[email protected]/crypto-js.min.js
// @require             https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.slim.min.js
// @require             https://cdn.jsdelivr.net/npm/[email protected]/dist/soda.min.js
// @grant               GM_addStyle
// @grant               GM_setClipboard
// @version             20220923.1-alpha
// @author              12redcircle
// @description         提取喜马拉雅网页上专辑和音频的播放链接
// @contributionURL     https://afdian.net/@yuyegongmian
// @license             WTFPL
// ==/UserScript==


(async function () {
  'use strict';

  /*************** 基础 *******************/
  const SECRET_KEY = 'himalaya-'; // 证书生成秘钥

  /**
   * 获取接口签名,header 中的 xm-sign
   * @returns
   */
  function getSign() {
    var secretKey = SECRET_KEY;
    var serverTime = window.XM_SERVER_CLOCK || 0;
    var clientTime = Date.now();
    var random = (t) => ~~(Math.random() * t);

    return `${md5(`${secretKey}${serverTime}`)}(${random(100)})${serverTime}(${random(100)})${clientTime}`;
  }

  /**
   * 获取服务器时间(无需xm-sign)
   * 备用方法,如果获取不到 window.XM_SERVER_CLOCK, serverTime = await getServerTime()
   * @returns 一个时间字符串
   */
  async function getServerTime() {
    return await fetch("https://www.ximalaya.com/revision/time")
      .then(res => res.text());
  }

  /**
   * 获取专辑播放列表
   * 注意:请在专辑界面调用
   * @param {*} albumId 专辑id
   * @param {*} pageNum 分页
   * @returns
   */
  async function getAlbumTrackList(albumId, pageNum) {
    const response = await fetch(`https://www.ximalaya.com/revision/album/v1/getTracksList?albumId=${albumId}&pageNum=${pageNum}&pageSize=100&sort=0`, {
      "credentials": "include",
      "headers": {
        "xm-sign": getSign(),
      },
      "method": "GET",
      "mode": "cors"
    });

    return response.json();
  }

  /**
   * 获取播放url列表(需要cookie,无需xm-sign)
   * 在任何界面均可调用
   * https://www.ximalaya.com/sound/${trackId}
   * @param {*} trackId 音轨id
   * @returns
   */
  async function getTrackList(trackId) {
    const response = await fetch(`https://mobile.ximalaya.com/mobile-playpage/track/v3/baseInfo/${Date.now()}?device=web&trackId=${trackId}&trackQualityLevel=1`, {
      "credentials": "include",
      "method": "GET",
      "mode": "cors"
    });
    return response.json();
  }

  /**
   * 获取播放url列表中的第一个直链
   * @param {*} playList
   * @returns
   */
  function getDownloadURL(playUrlList) {
    if (playUrlList && playUrlList.length) {
      const url = playUrlList[0].url;
      return decrypt(url);
    }
    return false;

    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);
    }
  }

  /*************** 对链接的操作 *******************/
  function isAlbumView() {
    return location.href.includes('/album/');
  }

  function isTrackView() {
    return location.href.includes('/sound/') || location.href.includes('/youshengshu/');
  }

  function getId(href) {
    return href.substring(href.lastIndexOf('/') + 1);
  }

  // 监听网页地址变化
  function pageViewChange$(callback) {

    let lastUrl = location.href;
    const observer = new MutationObserver(() => {
      const url = location.href;
      if (url !== lastUrl) {
        lastUrl = url;
        callback();
      }
    });

    observer.observe(document, {
      subtree: true,
      childList: true
    });
    callback();
  }

  /*************** 数据逻辑 *******************/

  async function getAlbumViewData(albumId) {
    const albumList = [];

    let pageNum = 1;
    while (1) {
      const {
        data
      } = await getAlbumTrackList(albumId, pageNum);
      const _albumList = data.tracks;
      if (_albumList.length === 0) {
        break;
      }
      albumList.push(..._albumList);
      pageNum++;
    }

    return albumList.map(function (album) {
      return {
        title: album.title,
        index: album.index,
        trackId: getId(album.url)
      };
    });
  }

  async function getTrackViewData(trackId) {
    const {
      trackInfo
    } = await getTrackList(trackId);

    const title = trackInfo.title;
    const url = getDownloadURL(trackInfo.playUrlList);

    return {
      title,
      url,
      trackId
    };
  }

  /*************** UI交互窗口 *******************/

  function addDragBehavior(selector) {
    const Drag = document.querySelector(selector);

    Drag.onmousedown = function (event) {
      const ev = event || window.event;
      ev?.stopPropagation();

      const disX = ev.clientX - Drag.offsetLeft;
      const disY = ev.clientY - Drag.offsetTop;

      Drag.onmousemove = function (event) {
        const ev = event || window.event;

        const left = ev.clientX - disX;
        const top = ev.clientY - disY;

        Drag.style.left = left + "px";
        Drag.style.top = top + "px";
      };
    };

    Drag.onmouseup = function () {
      Drag.onmousemove = null;
    };
  };

  const APPID = `__xmdownload__community__`;

  $(document.body)
    .append(`<div id="${APPID}"></div>`);

  GM_addStyle(`

    #__xmdownload__community__ {
      position: fixed;
      top: 0;
      line-height: 1.6;
      padding: 10px 20px;
      background-color: #dcdcdc;
      z-index: 20220923;
      min-height: 100px;
      max-height: 80vh;
      overflow: auto;
      background-color: rgba(240, 223, 175, 0.9);
      border: 2px solid black;
      box-shadow: 5px 5px 5px #000000;
    }

    #__xmdownload__community__:hover {
      cursor: move;
      user-select: none;
    }

    #__xmdownload__community__ .albumView table {
      width: 100%;
    }

    #__xmdownload__community__ .albumView table th{
      text-align: left;
    }

    #__xmdownload__community__ .albumView table td{
      min-width: 80px;
      max-width: 300px;
    }
  ` );

  addDragBehavior(`#${APPID}`);

  $(`#${APPID}`)
    .on('click', '.download_hook', async function (item) {
      const trackId = item.target.dataset.trackId;
      const {
        url
      } = await getTrackViewData(trackId);
      if (url) {
        window.open(url, '_blank');
      } else {
        alert(`获取下载链接失败,可能是因为【你正在尝试获取会员专享音频,但你目前不是会员】`);
      }
    });

  $(`#${APPID}`)
    .on('click', '.get_link_hook', async function (item) {
      const trackId = item.target.dataset.trackId;
      const {
        url
      } = await getTrackViewData(trackId);
      if (url) {
        GM_setClipboard(url, 'text/plain');
      } else {
        alert(`获取下载链接失败,可能是因为【你正在尝试获取会员专享音频,但你目前不是会员】`);
      }
    });

  const albumViewTpl = `
    <div class="albumView">
      <table>
        <thead>
          <th>序号</th>
          <th>标题(点击标题打开音频)</th>
          <th></th>
        </thead>
        <tbody>
          <tr soda-repeat="item in data">
            <td>{{item.index}}</td>
            <td><a class="download_hook" data-track-id="{{item.trackId}}">{{item.title}}</a></td>
            <td><a class="get_link_hook" data-track-id="{{item.trackId}}">复制链接</a></td>
          </tr>
        </tbody>
      </table>
    </div>
  `;

  const trackViewTpl = `
    <div class="trackView">
      <a class="download_hook" data-track-id="{{data.trackId}}" target="_blank">{{data.title}}(点击打开音频)</a>
      <a class="get_link_hook" data-track-id="{{item.trackId}}">复制链接</a>
    </div>
  `;


  const loadingViewTpl = `
    正在为你获取音频列表……
  `;

  pageViewChange$(async function () {
    $(`#${APPID}`)
      .html(soda(loadingViewTpl, {}))
      .show();

    if (isAlbumView()) {
      const albumId = getId(location.href);
      const albumData = await getAlbumViewData(albumId);
      $(`#${APPID}`)
        .html(soda(albumViewTpl, {
          data: albumData
        }));
    } else if (isTrackView()) {
      const trackId = getId(location.href);
      const trackData = await getTrackViewData(trackId);
      $(`#${APPID}`)
        .html(soda(trackViewTpl, {
          data: trackData
        }));
    } else {
      $(`#${APPID}`).hide();
    }
  });
})();