酷我音乐PC客户端歌词解密 - nodejs

酷我PC客户端歌词解密 - nodejs

语言:nodejs
网页端歌词接口:

http://m.kuwo.cn/newh5/singles/songinfoandlrc?musicId=198554068&httpsStatus=1

musicId=198554068就是歌曲的ID
· 这个比较好请求,但是这个接口很容易奔溃,比较慢
· 客户端的歌词是被加密过的,所以要进行解密,这个过程比较复杂,下面是 js 解密代码。
1.依次安装依赖
npm i iconv-lite
npm i needle
npm i process
npm i zlib

目录结构
	|_ index.js
	|_ package.json
	|_ .....
2.代码
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

// 音乐 rid
let musicId = 184600062

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: '接口无法访问了!',
  timeout: '请求超时',
  // unachievable: '重试下看能不能播放吧~',
  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 = 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()
    let v = process.versions.app.split('-')[0].split('.').map(n => n.length < 3 ? n.padStart(3, '0') : n).join('')
    let 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) => {
  let obj = {
    isCancelled: false,
  }
  obj.promise = new Promise((resolve, reject) => {
    obj.cancelFn = reject
    // console.log(`\nsend request---${url}`)
    fetchData(url, options.method, options, (err, resp, body) => {
    // 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 = {
  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)
    let startTime = Math.abs((offset + offset2) / (this.offset * 2))
    let 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 = 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()
    let lrc = []
    let lrcT = []
    for (const item of arr) {
      if (lrcSet.has(item.time)) {
        if (lrc.length < 2) continue
        const tItem = 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/)
    let tags = []
    let lrcArr = []
    for (let i = 0; i < lines.length; i++) {
        const line = lines[i].trim()
        let result = timeExp.exec(line)
        if (result) {
            let text = line.replace(timeExp, '').trim()
            let time = RegExp.$1
            if (/\.\d\d$/.test(time)) time += '0'
            let regexp = /<.*?>/g
            text = text.replace(regexp, "").replace(/\[by:.*?\](\n|$)/g, '').replace(/\[kuwo:.*?\](\n|$)/g, '')
            let 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(name, params) => {
    const lrc = await decodeLyrics(Buffer.from(params.lrcBase64, 'base64'), isGetLyricx)
    return Buffer.from(lrc).toString('base64')
}
const decodeLyric = base64Data => rendererInvoke('handle_kw_decode_lyric', base64Data)
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'))
        }
        let msg = {
            data: {
                lrclist: lrcInfo.lrc
            },
            status: 200
        }
        return msg
    })
})


// 输出
const rrr = async()=>{
    let asd = async()=>{
        return await new Promise((res,rej)=>{
            requestObj.promise.then((re)=>{
                res(re)
            })
        })
    }
    let as = await asd()
    console.log(as.data.lrclist)
}

console.log(rrr())

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
本来在做项目,看到酷我音乐盒的歌词显示挺有趣的,模仿做了一个不完整的。 (只有滚动显示,没有节奏显示)。 原理: (1)定义一个派生自CStatic类的CKaraokeLyricCtrl类(歌词控件),自绘制风格 ; (2)准备一个背景位图(保存在CKaraokeLyricCtrl::m_dcBK中); (3)设置两个计数器(ID分别为1和2),启动自绘制,1用来显示节奏(未实现,只 有框架),2用来滚动歌词; (4)自绘制函数中,将绘制的滚动歌词和背景位图混合,然后输出到屏幕上。滚动 歌词的绘制使用GDI+的Graphics::DrawString函数,歌词文本的大小、位置、字体和 透明度均自动计算和变化,模仿酷我音乐盒的形式。 以上功能均封闭实现在CKaraokeLyricCtrl类中。该类可以直接使用(见下边的使用步骤)。 使用步骤: (1)CKaraokeLyric::InitInstance中启动GDI+; (2)在CKaraokeLyricView::OnInitialUpdate中,创建歌词控件 (CKaraokeLyricCtrl类),其大小和CKaraokeLyricView视图相同,即覆盖了后者; (3)在菜单项响应中,使用CKaraokeLyricCtrl::ReadLyric读取歌词文件,再使用 CKaraokeLyricCtrl::Start即可启动歌词的滚动显示 未实现部分:(歌词的节拍显示) 虽然没有实现,但思路大致是:在后台先用另外一种颜色绘制当前突出显示的歌词(即字体最大的一行歌词),根据歌曲节奏,将还未唱出部分全部涂黑,然后和屏幕上的当前行突出歌词进行混合。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值