QiuYaohong / kuwoMusicApi

酷我音乐API Node.js 版 酷我音乐 API
Apache License 2.0
208 stars 55 forks source link

获取歌词改用酷我电脑客户端 #14

Open UPman24 opened 1 year ago

UPman24 commented 1 year ago

web官网歌词页经常奔溃,不稳定,下面是改用PC客户端稳定获取歌词的方法

1.在根目录引入如下 4 个包(由于酷我PC客户端歌词是加密的所以需要引入新的包进行解密)

npm install iconv-lite

npm install needle

npm install process

npm install zlib

2.找到 app => service => lrc.ts

3.替换代码(代码是兼容的不影响其他模块)

4.原本代码如下:

const BaseService = require('./BaseService')

export default class Lrc extends BaseService {
  async LrcRes (musicId) {
    return this.commonRequest(`http://m.kuwo.cn/newh5/singles/songinfoandlrc?musicId=${musicId}&httpsStatus=1`)
  }
}

5.将上面的代码替换成下面这样的代码

const BaseService = require('./BaseService')

const needle = require('needle')
const process = require('process')
const deflateRaw = require('zlib')
const { inflate } = require('zlib')
const iconv = require('iconv-lite')

const bufkey = Buffer.from('yeelion')
const bufkeylen = bufkey.length
const buildParams = (id, isGetLyricx) => {
  let params = `user=12345,web,web,web&requester=localhost&req=1&rid=MUSIC_${id}`
  if (isGetLyricx) params += '&lrcx=1'
  const bufstr = Buffer.from(params)
  const bufstrlen = bufstr.length
  const output = new Uint16Array(bufstrlen)
  let i = 0
  while (i < bufstrlen) {
    let j = 0
    while (j < bufkeylen && i < bufstrlen) {
      output[i] = bufkey[j] ^ bufstr[i]
      i++
      j++
    }
  }
  return Buffer.from(output).toString('base64')
}

const cancelHttp = requestObj => {
  // console.log(requestObj)
  if (!requestObj) return
  // console.log('cancel:', requestObj)
  if (!requestObj.abort) return
  requestObj.abort()
}

const requestMsg = {
  fail: '请求异常,可以多试几次,若还是不行就换一首吧',
  unachievable: '哦No...接口无法访问了!',
  timeout: '请求超时',
  // unachievable: '哦No...接口无法访问了!已帮你切换到临时接口,重试下看能不能播放吧~',
  notConnectNetwork: '无法连接到服务器',
  cancelRequest: '取消http请求',
}

const request = (url, options, callback) => {
  let data
  if (options.body) {
    data = options.body
  } else if (options.form) {
    data = options.form
    // data.content_type = 'application/x-www-form-urlencoded'
    options.json = false
  } else if (options.formData) {
    data = options.formData
    // data.content_type = 'multipart/form-data'
    options.json = false
  }
  options.response_timeout = options.timeout

  return needle.request(options.method || 'get', url, data, options, (err, resp, body) => {
    if (!err) {
      body = resp.body = resp.raw.toString()
      try {
        resp.body = JSON.parse(resp.body)
      } catch (_) { }
      body = resp.body
    }
    callback(err, resp, body)
  }).request
}

const defaultHeaders = {
  'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36',
}

const handleDeflateRaw: any = data => new Promise((resolve, reject) => {
  deflateRaw(data, (err, buf) => {
    if (err) return reject(err)
    resolve(buf)
  })
})

const regx = /(?:\d\w)+/g

const fetchData = async (url, method, {
  headers = {},
  format = 'json',
  timeout = 15000,
  ...options
}, callback) => {
  headers = Object.assign({}, headers)
  const bHh = '624868746c'
  if (headers[bHh]) {
    const path = url.replace(/^https?:\/\/[\w.:]+\//, '/')
    let s = Buffer.from(bHh, 'hex').toString()
    s = s.replace(s.substr(-1), '')
    s = Buffer.from(s, 'base64').toString()
    const v = process.versions.app.split('-')[0].split('.').map(n => n.length < 3 ? n.padStart(3, '0') : n).join('')
    const v2 = process.versions.app.split('-')[1] || ''
    headers[s] = !s || `${(await handleDeflateRaw(Buffer.from(JSON.stringify(`${path}${v}`.match(regx), null, 1).concat(v)).toString('base64'))).toString('hex')}&${parseInt(v)}${v2}`
    delete headers[bHh]
  }
  return request(url, {
    ...options,
    method,
    headers: Object.assign({}, defaultHeaders, headers),
    timeout,
    json: format === 'json',
  }, (err, resp, body) => {
    if (err) return callback(err, null)
    callback(null, resp, body)
  })
}

const buildHttpPromose = (url, options) => {
  const obj: any = {
    isCancelled: false,
  }
  obj.promise = new Promise((resolve, reject) => {
    obj.cancelFn = reject
    // console.log(`\nsend request---${url}`)
    fetchData(url, options.method, options, (err, resp) => {
      // options.isShowProgress && window.api.hideProgress()
      //   console.log(`\nresponse---${url}`)
      //   console.log(body)
      obj.requestObj = null
      obj.cancelFn = null
      if (err) return reject(err)
      resolve(resp)
    }).then(ro => {
      obj.requestObj = ro
      if (obj.isCancelled) obj.cancelHttp()
    })
  })
  obj.cancelHttp = () => {
    if (!obj.requestObj) return obj.isCancelled = true
    cancelHttp(obj.requestObj)
    obj.requestObj = null
    obj.promise = obj.cancelHttp = null
    obj.cancelFn(new Error(requestMsg.cancelRequest))
    obj.cancelFn = null
  }
  return obj
}
const httpFetch = (url, options = { method: 'get' }) => {
  const requestObj = buildHttpPromose(url, options)
  requestObj.promise = requestObj.promise.catch(err => {
    if (err.message === 'socket hang up') {
      return Promise.reject(new Error(requestMsg.unachievable))
    }
    switch (err.code) {
      case 'ETIMEDOUT':
      case 'ESOCKETTIMEDOUT':
        return Promise.reject(new Error(requestMsg.timeout))
      case 'ENOTFOUND':
        return Promise.reject(new Error(requestMsg.notConnectNetwork))
      default:
        return Promise.reject(err)
    }
  })
  return requestObj
}
const lrcTools: any = {
  rxps: {
    wordLine: /^(\[\d{1,2}:.*\d{1,4}\])\s*(\S+(?:\s+\S+)*)?\s*/,
    tagLine: /\[(ver|ti|ar|al|offset|by|kuwo):\s*(\S+(?:\s+\S+)*)\s*\]/,
    wordTimeAll: /<(-?\d+),(-?\d+)(?:,-?\d+)?>/g,
    wordTime: /<(-?\d+),(-?\d+)(?:,-?\d+)?>/,
  },
  offset: 1,
  offset2: 1,
  isOK: false,
  lines: [],
  tags: [],
  getWordInfo(str, str2, prevWord) {
    const offset = parseInt(str)
    const offset2 = parseInt(str2)
    const startTime = Math.abs((offset + offset2) / (this.offset * 2))
    const endTime = Math.abs((offset - offset2) / (this.offset2 * 2)) + startTime
    if (prevWord) {
      if (startTime < prevWord.endTime) {
        prevWord.endTime = startTime
        if (prevWord.startTime > prevWord.endTime) {
          prevWord.startTime = prevWord.endTime
        }
        prevWord.newTimeStr = ''
      }
    }
    return {
      startTime,
      endTime,
      timeStr: '',
    }
  },
  parseLine(line) {
    if (line.length < 6) return
    let result = this.rxps.wordLine.exec(line)
    if (result) {
      const time = result[1]
      let words = result[2]
      if (words == null) {
        words = ''
      }
      const wordTimes = words.match(this.rxps.wordTimeAll)
      if (!wordTimes) return
      // console.log(wordTimes)
      let preTimeInfo
      for (const timeStr of wordTimes) {
        const result = this.rxps.wordTime.exec(timeStr)
        const wordInfo = this.getWordInfo(result[1], result[2], preTimeInfo)
        words = words.replace(timeStr, wordInfo.timeStr)
        if (preTimeInfo?.newTimeStr) words = words.replace(preTimeInfo.timeStr, preTimeInfo.newTimeStr)
        preTimeInfo = wordInfo
      }
      this.lines.push(time + words)
      return
    }
    result = this.rxps.tagLine.exec(line)
    if (!result) return
    if (result[1] === 'kuwo') {
      let content = result[2]
      if (content !== null && content.includes('][')) {
        content = content.substring(0, content.indexOf(']['))
      }
      const valueOf = parseInt(content, 8)
      this.offset = Math.trunc(valueOf / 10)
      this.offset2 = Math.trunc(valueOf % 10)
      if (this.offset === 0 || Number.isNaN(this.offset) || this.offset2 === 0 || Number.isNaN(this.offset2)) {
        this.isOK = false
      }
    } else {
      this.tags.push(line)
    }
  },
  parse(lrc) {
    // console.log(lrc)
    const lines = lrc.split(/\r\n|\r|\n/)
    const tools = Object.create(this)
    tools.isOK = true
    tools.offset = 1
    tools.offset2 = 1
    tools.lines = []
    tools.tags = []
    for (const line of lines) {
      if (!tools.isOK) throw new Error('failed')
      tools.parseLine(line)
    }
    if (!tools.lines.length) return ''
    let lrcs = tools.lines.join('\n')
    if (tools.tags.length) lrcs = `${tools.tags.join('\n')}\n${lrcs}`
    // console.log(lrcs)
    return lrcs
  },
}
const isGetLyricx = true
const handleInflate = data => new Promise((resolve, reject) => {
  inflate(data, (err, result) => {
    if (err) return reject(err)
    resolve(result)
  })
})
const bufKey = Buffer.from('yeelion')
const bufKeyLen = bufKey.length
const decodeLyrics = async (buf, isGetLyricx) => {
  if (buf.toString('utf8', 0, 10) !== 'tp=content') return ''
  const lrcData: any = await handleInflate(buf.slice(buf.indexOf('\r\n\r\n') + 4))
  if (!isGetLyricx) return iconv.decode(lrcData, 'gb18030')
  const bufStr = Buffer.from(lrcData.toString(), 'base64')
  const bufStrLen = bufStr.length
  const output = new Uint16Array(bufStrLen)
  let i = 0
  while (i < bufStrLen) {
    let j = 0
    while (j < bufKeyLen && i < bufStrLen) {
      output[i] = bufStr[i] ^ bufKey[j]
      i++
      j++
    }
  }
  return iconv.decode(Buffer.from(output), 'gb18030')
}
const timeExp = /^\[([\d:.]*)\]{1}/g
const sortLrcArr = (arr) => {
  const lrcSet = new Set()
  const lrc: any = []
  const lrcT: any = []
  for (const item of arr) {
    if (lrcSet.has(item.time)) {
      if (lrc.length < 2) continue
      const tItem: any = lrc.pop()
      tItem.time = lrc[lrc.length - 1].time
      lrcT.push(tItem)
      lrc.push(item)
    } else {
      lrc.push(item)
      lrcSet.add(item.time)
    }
  }
  return {
    lrc,
    lrcT,
  }
}
const parseLrc = (lrc) => {
  const lines = lrc.split(/\r\n|\r|\n/)
  const tags: any = []
  const lrcArr: any = []
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i].trim()
    const result = timeExp.exec(line)
    if (result) {
      let text = line.replace(timeExp, '').trim()
      let time = RegExp.$1
      if (/\.\d\d$/.test(time)) time += '0'
      const regexp = /<.*?>/g
      text = text.replace(regexp, '').replace(/\[by:.*?\](\n|$)/g, '').replace(/\[kuwo:.*?\](\n|$)/g, '')
      const times = time.split(':');
      time = (parseFloat(times[0]) * 60 + parseFloat(times[1])).toFixed(2);
      lrcArr.push({
        time,
        lineLyric: text,
      })
    } else if (lrcTools.rxps.tagLine.test(line)) {
      tags.push(line)
    }
  }
  const lrcInfo = sortLrcArr(lrcArr)
  return lrcInfo
}

const rendererInvoke = async (params) => {
  const lrc = await decodeLyrics(Buffer.from(params.lrcBase64, 'base64'), isGetLyricx)
  return Buffer.from(lrc).toString('base64')
}
const decodeLyric = base64Data => rendererInvoke(base64Data)

export default class Lrc extends BaseService {
  async LrcRes(musicId) {
    const requestObj = httpFetch(`http://newlyric.kuwo.cn/newlyric.lrc?${buildParams(musicId, isGetLyricx)}`)
    requestObj.promise = requestObj.promise.then(({ statusCode, body, raw }) => {
      if (statusCode !== 200) return Promise.reject(new Error(JSON.stringify(body)))
      return decodeLyric({ lrcBase64: raw.toString('base64'), isGetLyricx }).then(base64Data => {
        let lrcInfo
        lrcInfo = parseLrc(Buffer.from(base64Data, 'base64').toString())
        try {
          lrcInfo = parseLrc(Buffer.from(base64Data, 'base64').toString())
        } catch (err) {
          return Promise.reject(new Error('Get lyric failed'))
        }
        const msg = {
          data: {
            lrclist: lrcInfo.lrc
          },
          status: 200
        }
        return msg
      })
    })
    const asd = async () => {
      return await new Promise((resolve) => {
        requestObj.promise.then((re) => {
          resolve(re)
        })
      })
    }
    const as = await asd()
    return as;
    // return this.commonRequest(`http://m.kuwo.cn/newh5/singles/songinfoandlrc?musicId=${musicId}&httpsStatus=1`)
  }
}

6.进入 .eslintrc 文件,把下面代码替换(此替换不影响其他地方功能)

{
  "extends": ["eslint-config-egg/typescript","eslint-config-standard","eslint:recommended","plugin:jsdoc/recommended"],
  "parser": "@typescript-eslint/parser",
  "plugins": ["jsdoc"],
    "rules": {
        "comma-dangle":0,
        "operator-linebreak":["error","before"],
        "space-before-function-paren":0,
        "linebreak-style":0,
        "no-var-requires":0,
        "no-return-assign":"off",
        "default-case":"off",
        "no-useless-constructor":"off",
        "no-unused-vars":0,
        "jsdoc/require-param-description":"off",
        "jsdoc/check-tag-names": 0,
        "jsdoc/no-undefined-types":0,
        "jsdoc/valid-types":0,
        "jsdoc/tag-lines":0,
        "jsdoc/require-returns":0,
        "jsdoc/check-param-names":0,
        "no-bitwise":0,
        "no-confusing-arrow":0,
        "arrow-parens":0,
        "semi":0
    },
    "overrides":[
     {
        "files":["*.ts"], 
        "rules":{
            "@typescript-eslint/no-unused-vars":0,
            "@typescript-eslint/semi":0,
            "@typescript-eslint/no-var-requires":0,
            "@typescript-eslint/no-useless-constructor":0
        }
    }
    ],
    "globals": {
        "_":"readonly",
        "app":true
    },
    "env": {
        "node": true
    }
}

7.使用 npm run ci 编译成 js 代码

8.最后启动项目npm run start

9.可以进行歌词接口代替,使用桌面客户端解密歌词接口更稳定,更快。

wifi-left commented 1 year ago

感谢大佬~ 本人改写了个php版本 也分享下: kuwolrc.php

<?php
include("./libs.php");
// 定义strToHexBuffer函数,接受一个字符串参数
$get_id = 0;
if (empty($_GET['id'])) {
    http_response_code(403);
    echo '{"code":403,"msg":"Wrong params."}';
    return;
}
$get_id = $_GET['id'];
$bufkey = strToHexBuffer('yeelion');
$bufkeylen = count($bufkey);
// 调用函数,得到返回值(16进制的buffer数组)

function buildParams($id, $isGetLyrics = true)
{
    $params = "user=1,web,web,web&requester=localhost&req=1&rid=MUSIC_$id";
    if ($isGetLyrics) $params .= '&lrcx=1';
    $bufstr = strToHexBuffer($params);
    $bufstrlen = count($bufstr);
    $output = $bufstr;
    $bufkeylen = $GLOBALS['bufkeylen'];
    $bufkey = $GLOBALS['bufkey'];
    for ($i = 0; $i < $bufstrlen; $i++) {
        $output[$i] = 0;
    }
    $i = 0;
    while ($i < $bufstrlen) {
        $j = 0;
        while ($j < $bufkeylen && $i < $bufstrlen) {
            $output[$i] = $bufkey[$j] ^ $bufstr[$i];
            $i++;
            $j++;
        }
    }
    //print_r($output);
    $string = implode($output);

    // 将字符串用base64方式编码
    $encoded = base64_encode($string);
    return $encoded;
}
function decodeLyrics($base64, $isGetLyrics = true)
{
    $str = ($base64);
    // echo $str;
    // echo (substr($str,0,10) == "tp=content")?"true":"false";
    if (substr($str, 0, 10) != 'tp=content') return false;
    // $buf = strToHexBuffer($str);
    $newstr = substr($str, strpos($str, "\r\n\r\n") + 4);
    // echo $newstr;
    $unzipstr = base64_decode(gzuncompress($newstr));
    $bufStr = strToHexBuffer($unzipstr);
    $bufStrlen = count($bufStr);
    $output = $bufStr;
    $i = 0;
    $bufkeylen = $GLOBALS['bufkeylen'];
    $bufkey = $GLOBALS['bufkey'];
    while($i< $bufStrlen){
        $j = 0;
        while($j<$bufkeylen && $i<$bufStrlen){
            $output[$i] = $bufStr[$i] ^ $bufkey[$j];
            $i++;
            $j++;
        }
    }
    $result = implode($output);
    return detect_encoding($result,'utf8');
    // return true;
}
/**
 * @ string 需要转换的文字
 * @ encoding 目标编码
 **/
function detect_encoding($string, $encoding = 'gbk')
{
    $is_utf8 = preg_match('%^(?:[\x09\x0A\x0D\x20-\x7E]| [\xC2-\xDF][\x80-\xBF]| \xE0[\xA0-\xBF][\x80-\xBF] | [\xE1-\xEC\xEE\xEF][\x80-\xBF]{2}  | \xED[\x80-\x9F][\x80-\xBF] | \xF0[\x90-\xBF][\x80-\xBF]{2} | [\xF1-\xF3][\x80-\xBF]{3} | \xF4[\x80-\x8F][\x80-\xBF]{2} )*$%xs', $string);
    if ($is_utf8 && $encoding == 'utf8') {
        return $string;
    } elseif ($is_utf8) {
        return mb_convert_encoding($string, $encoding, "UTF-8");
    } else {
        return mb_convert_encoding($string, $encoding, 'gbk,gb2312,big5');
    }
}
$url = "http://newlyric.kuwo.cn/newlyric.lrc?" . buildParams($get_id);
//echo $url;
$data = fetchURL($url, false);
if ($data == false) {
    http_response_code(500);
    echo '{"msg":"获取失败 (Step#1)","code":500}';
}
$result = decodeLyrics($data);
if ($result == false) {
    http_response_code(500);
    echo '{"msg":"获取失败 (Step#2)","code":500}';
}
echo $result;

libs.php

function strToHexBuffer($str)
{
    // 将字符串转换为字节数组
    $bytes = unpack("C*", $str);
    // 定义输出数组,用于存储16进制的buffer元素
    $output = array();
    // 循环遍历字节数组,将每个字节转换为16进制,并添加到输出数组中
    foreach ($bytes as $byte) {
        // 使用sprintf函数格式化16进制,并在前面补0(如果需要)
        $hex = sprintf("%02x", $byte);
        // 使用pack函数将16进制转换为二进制,并添加到输出数组中
        $output[] = pack("H*", $hex);
    }
    // 返回输出数组
    return $output;
}
function fetchURL($url, $ispost = false, $postcontent = "")
{
    //echo $url;
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
    curl_setopt($ch, CURLOPT_TIMEOUT, 8);
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE);
    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0)');
    curl_setopt($ch, CURLOPT_HTTPHEADER, array("Cache-Control: no-cache"));
    curl_setopt($ch, CURLOPT_COOKIE, "NMTID=00OTrNMnhnWaFznwESKkN7usch8O14AAAGDKs_klA;");
    if ($ispost) {
        curl_setopt($ch, CURLOPT_POST, 1);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $postcontent);
    }
    $output = curl_exec($ch);
    // echo json_encode(curl_getinfo($ch));
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    if ($output === false) {
        if (in_array(intval(curl_errno($ch)), [7, 28], true)) {
            echo '{"success":"fail","msg":"连接超时,请重试。","code":3}';
            return false;
            //超时的处理代码
        } else if (in_array(intval(curl_errno($ch)), [3], true)) {
            echo '{"success":"fail","msg":"C++ CURL 不支持的 URL:' . $url . '","code":4}';
        } else {
            echo '{"success":"fail","msg":"[' . curl_errno($ch) . '] ' . curl_strerror(curl_errno($ch)) . '; ' . curl_error($ch) . '","code":4}';
            return false;
        }
    }
    if ($httpCode >= 400 && $httpCode < 404) {
        echo '{"success":"fail","msg":"无法访问该文件,请联系站点管理员。","code":5}';
        return false;
    } else if ($httpCode >= 404 && $httpCode < 500) {
        echo '{"success":"fail","msg":"服务器无法找到文件。","code":5}';
        return false;
    } else if ($httpCode >= 500) {
        echo '{"success":"fail","msg":"服务器发生错误,请稍后重试。(Http Status:' . $httpCode . ')","code":6}';
        return false;
    }
    curl_close($ch);
    return $output;
}