Files
Koneko_api_for_QZ-Music/Koneko_QQ音乐_v0.0.2.js

451 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @name QQ音乐 - Koneko
* @description 聚合音源插件: 官方搜索 + 多API音源容灾
* @version 0.0.2
* @author Miao-moe
*
* 环境变量:
* ceru_key - 聆澜API密钥可选
*/
'use strict'
var https = require('https')
var http = require('http')
var crypto = require('crypto')
var env = global.env || {}
var CERU_KEY = env.ceru_key || ''
var HEADERS_COMMON = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
function mapBr(q) {
if (q === '128k' || q === 'standard') return '128k'
if (q === '320k' || q === 'exhigh') return '320k'
return '999k'
}
function httpGet(url, headers, timeout) {
return new Promise(function(resolve, reject) {
var mod = url.indexOf('https') === 0 ? https : http
var req = mod.get(url, { headers: headers || {}, timeout: timeout || 8000 }, function(res) {
var data = ''
res.on('data', function(chunk) { data += chunk })
res.on('end', function() {
try { resolve(JSON.parse(data)) } catch(e) { resolve(data) }
})
})
req.on('error', function(err) { reject(err) })
req.on('timeout', function() { req.destroy(); reject(new Error('timeout')) })
})
}
function httpPost(url, body, headers, timeout) {
return new Promise(function(resolve, reject) {
var mod = url.indexOf('https') === 0 ? https : http
var postData = typeof body === 'string' ? body : JSON.stringify(body)
var opts = {
method: 'POST',
headers: {},
timeout: timeout || 10000
}
if (headers) {
for (var k in headers) { opts.headers[k] = headers[k] }
}
opts.headers['Content-Type'] = 'application/json'
opts.headers['Content-Length'] = Buffer.byteLength(postData)
var req = mod.request(url, opts, function(res) {
var data = ''
res.on('data', function(chunk) { data += chunk })
res.on('end', function() {
try { resolve(JSON.parse(data)) } catch(e) { resolve(data) }
})
})
req.on('error', function(err) { reject(err) })
req.on('timeout', function() { req.destroy(); reject(new Error('timeout')) })
req.write(postData)
req.end()
})
}
var PART_1_INDEXES = [23, 14, 6, 36, 16, 40, 7, 19]
var PART_2_INDEXES = [16, 1, 32, 12, 19, 27, 8, 5]
var SCRAMBLE_VALUES = [89, 39, 179, 150, 218, 82, 58, 252, 177, 52, 186, 123, 120, 64, 242, 133, 143, 161, 121, 179]
function zzcSign(text) {
var hash = crypto.createHash('sha1').update(text).digest('hex')
var part1 = ''
for (var i = 0; i < PART_1_INDEXES.length; i++) { part1 += hash[PART_1_INDEXES[i]] }
var part2 = ''
for (var i = 0; i < PART_2_INDEXES.length; i++) { part2 += hash[PART_2_INDEXES[i]] }
var part3 = []
for (var i = 0; i < SCRAMBLE_VALUES.length; i++) {
part3.push(SCRAMBLE_VALUES[i] ^ parseInt(hash.slice(i * 2, i * 2 + 2), 16))
}
var b64Part = Buffer.from(part3).toString('base64').replace(/[\/+=]/g, '')
return ('zzc' + part1 + b64Part + part2).toLowerCase()
}
function signRequest(data) {
var sign = zzcSign(JSON.stringify(data))
return httpPost('https://u.y.qq.com/cgi-bin/musics.fcg?sign=' + sign, data, {
'User-Agent': 'QQMusic 14090508(android 12)'
})
}
function formatPlayTime(seconds) {
if (!seconds || isNaN(seconds)) return '--/--'
var m = Math.floor(seconds / 60)
var s = seconds % 60
return m + ':' + (s < 10 ? '0' : '') + s
}
function formatSingerName(singers) {
if (!singers || singers.length === 0) return ''
var names = []
for (var i = 0; i < singers.length; i++) {
if (singers[i].name) names.push(singers[i].name)
}
return names.join('、')
}
function formatSize(bytes) {
if (!bytes) return ''
var n = parseFloat(bytes)
if (isNaN(n) || n < 0) return ''
return (n / (1024 * 1024)).toFixed(2) + 'MB'
}
var musicSearch = {
limit: 30,
total: 0,
page: 0,
allPage: 1,
musicSearch: function(str, page, limit, retryNum) {
var self = this
if (retryNum === undefined) retryNum = 0
if (retryNum > 3) return Promise.reject(new Error('搜索失败'))
var data = {
comm: {
ct: '11', cv: '14090508', v: '14090508', tmeAppID: 'qqmusic',
phonetype: 'EBG-AN10', deviceScore: '553.47', devicelevel: '50', newdevicelevel: '20',
rom: 'HuaWei/EMOTION/EmotionUI_14.2.0', os_ver: '12',
OpenUDID: '0', OpenUDID2: '0', QIMEI36: '0', udid: '0',
chid: '0', aid: '0', oaid: '0', taid: '0', tid: '0', wid: '0', uid: '0', sid: '0',
modeSwitch: '6', teenMode: '0', ui_mode: '2', nettype: '1020', v4ip: ''
},
req: {
module: 'music.search.SearchCgiService',
method: 'DoSearchForQQMusicMobile',
param: {
search_type: 0, searchid: Math.random().toString().slice(2),
query: str, page_num: page, num_per_page: limit,
highlight: 0, nqc_flag: 0, multi_zhida: 0, cat: 2, grp: 1, sin: 0, sem: 0
}
}
}
return signRequest(data).then(function(body) {
if (!body || typeof body !== 'object' || body.code !== 0 || !body.req || body.req.code !== 0) {
return self.musicSearch(str, page, limit, retryNum + 1)
}
return body.req.data
})
},
handleResult: function(rawList) {
if (!rawList || rawList.length === 0) return []
var list = []
for (var i = 0; i < rawList.length; i++) {
var item = rawList[i]
if (!item.file || !item.file.media_mid) continue
var albumId = ''
var albumName = ''
if (item.album) { albumName = item.album.name; albumId = item.album.mid }
var picUrl = ''
if (albumId === '' || albumId === '空') {
if (item.singer && item.singer.length) {
picUrl = 'https://y.gtimg.cn/music/photo_new/T001R500x500M000' + item.singer[0].mid + '.jpg'
}
} else {
picUrl = 'https://y.gtimg.cn/music/photo_new/T002R500x500M000' + albumId + '.jpg'
}
var qualities = {}
if (item.file && item.file.size_128mp3) qualities.standard = formatSize(item.file.size_128mp3)
if (item.file && item.file.size_320mp3) qualities.exhigh = formatSize(item.file.size_320mp3)
if (item.file && item.file.size_flac) qualities.lossless = formatSize(item.file.size_flac)
if (item.file && item.file.size_hires) qualities.hires = formatSize(item.file.size_hires)
list.push({
id: String(item.mid),
name: item.name + (item.title_extra || ''),
artists: formatSingerName(item.singer),
source: 'tx',
pic: picUrl,
mPic: picUrl,
sPic: picUrl,
albumName: albumName,
albumId: String(albumId || ''),
interval: String(formatPlayTime(item.interval) || '--/--'),
qualities: qualities
})
}
return list
},
search: function(str, page, limit) {
var self = this
if (!page) page = 1
if (limit == null) limit = this.limit
return this.musicSearch(str, page, limit).then(function(data) {
if (!data || typeof data !== 'object') {
return { list: [], allPage: 1, limit: 30, total: 0, source: 'tx' }
}
var list = self.handleResult(data.body && data.body.item_song ? data.body.item_song : [])
if (!list || list.length === 0) {
return { list: [], allPage: 1, limit: 30, total: 0, source: 'tx' }
}
self.total = data.meta && data.meta.estimate_sum ? data.meta.estimate_sum : 0
self.page = page
self.allPage = Math.ceil(self.total / limit)
return { list: list, allPage: self.allPage, limit: limit, total: self.total, source: 'tx' }
}).catch(function(e) {
return { list: [], allPage: 1, limit: 30, total: 0, source: 'tx' }
})
}
}
var tipSearch = {
getList: function(str) {
return httpGet(
'https://c.y.qq.com/splcloud/fcgi-bin/smartbox_new.fcg?is_xml=0&format=json&key=' + encodeURIComponent(str) + '&loginUin=0&hostUin=0&inCharset=utf8&outCharset=utf-8&notice=0&platform=yqq&needNewCode=0',
{ Referer: 'https://y.qq.com/portal/player.html' }
).then(function(body) {
if (!body || typeof body !== 'object' || body.code !== 0) return []
var result = { order: [], songs: [], artists: [], albums: [] }
if (body.data && body.data.song && body.data.song.count > 0) result.order.push('songs')
if (body.data && body.data.singer && body.data.singer.count > 0) result.order.push('artists')
if (body.data && body.data.album && body.data.album.count > 0) result.order.push('albums')
if (body.data && body.data.song && body.data.song.itemlist) {
for (var i = 0; i < body.data.song.itemlist.length; i++) {
var item = body.data.song.itemlist[i]
result.songs.push({ name: item.name, artist: { name: item.singer } })
}
}
if (body.data && body.data.singer && body.data.singer.itemlist) {
for (var i = 0; i < body.data.singer.itemlist.length; i++) {
result.artists.push({ name: body.data.singer.itemlist[i].name })
}
}
if (body.data && body.data.album && body.data.album.itemlist) {
for (var i = 0; i < body.data.album.itemlist.length; i++) {
result.albums.push({ name: body.data.album.itemlist[i].name })
}
}
return result
}).catch(function(e) { return [] })
}
}
var hotSearch = {
getList: function() {
var data = {
comm: { ct: '19', cv: '1803', guid: '0', patch: '118' },
hotkey: {
method: 'GetHotkeyForQQMusicPC',
module: 'tencent_musicsoso_hotkey.HotkeyService',
param: { search_id: '', uin: 0 }
}
}
return httpPost('https://u.y.qq.com/cgi-bin/musicu.fcg', data, {
Referer: 'https://y.qq.com/portal/player.html'
}).then(function(body) {
if (!body || typeof body !== 'object' || body.code !== 0 || !body.hotkey || !body.hotkey.data) return []
var list = []
for (var i = 0; i < body.hotkey.data.vec_hotkey.length; i++) {
list.push(body.hotkey.data.vec_hotkey[i].query)
}
return list
}).catch(function(e) { return [] })
}
}
function buildApis(songId, q) {
var br = mapBr(q)
var apis = []
if (CERU_KEY) {
apis.push({
name: '聆澜',
url: 'https://source.shiqianjiang.cn/api/music/url?source=tx&songId=' + songId + '&quality=' + q,
headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-API-Key': CERU_KEY },
extract: function(res) { return res && res.code === 200 && res.url ? res.url : null }
})
}
apis.push(
{
name: 'HUIBQ',
url: 'https://lxmusicapi.onrender.com/url/tx/' + songId + '/' + q,
headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-Request-Key': 'share-v3' },
extract: function(res) { return res && res.code === 0 && res.url ? res.url : null }
},
{
name: '忆音',
url: 'https://music.3e0.cn/?server=tencent&type=url&id=' + songId,
headers: HEADERS_COMMON,
extract: function(res) {
if (res && res.url) return res.url
if (typeof res === 'string' && res.indexOf('http') === 0) return res
return null
}
},
{
name: '星海',
url: 'https://music-api.gdstudio.xyz/api.php?types=url&source=tencent&id=' + songId + '&br=' + br,
headers: HEADERS_COMMON,
extract: function(res) {
if (res && res.url) return res.url
if (res && res.data && res.data.url) return res.data.url
return null
}
},
{
name: '收集QQ',
url: 'https://cyapi.top/API/qq_music.php?apikey=4d6f7369632d6170692e63656e6775696769692e636f6d&type=json&mid=' + songId,
headers: HEADERS_COMMON,
extract: function(res) {
if (res && res.url) return res.url
if (res && res.data && res.data.url) return res.data.url
return null
}
},
{
name: '念心',
url: 'https://music.nxinxz.com/kgqq/tx.php?id=' + songId + '&level=' + q + '&type=mp3',
headers: HEADERS_COMMON,
extract: function(res) {
if (res && res.url) return res.url
if (res && res.data && res.data.url) return res.data.url
return null
}
},
{
name: '长青',
url: 'http://175.27.166.236/kgqq/qq.php?type=mp3&id=' + songId + '&level=' + q,
headers: HEADERS_COMMON,
extract: function(res) {
if (res && res.url) return res.url
if (res && res.data && res.data.url) return res.data.url
return null
}
},
{
name: '星海备',
url: 'https://music-dl.sayqz.com/api/?source=qq&id=' + songId + '&type=url&br=' + q,
headers: HEADERS_COMMON,
extract: function(res) {
if (res && res.url) return res.url
if (res && res.data && res.data.url) return res.data.url
return null
}
},
{
name: 'fish',
url: 'https://m-api.ceseet.me/url/tx/' + songId + '/' + q,
headers: HEADERS_COMMON,
extract: function(res) {
if (res && res.url) return res.url
if (res && res.data && res.data.url) return res.data.url
return null
}
},
{
name: 'HYW',
url: 'https://music.bxa241d4.shop/api/music/url?source=tx&songId=' + songId + '&quality=' + q,
headers: { 'User-Agent': HEADERS_COMMON['User-Agent'], 'X-Card-Key': 'TF-VSS0-8Y73-U1AW-GEXJ' },
extract: function(res) {
if (res && res.url) return res.url
if (res && res.code === 200 && res.data && res.data.url) return res.data.url
return null
}
}
)
return apis
}
function getUrl(songId, quality) {
var q = quality || '320k'
var apis = buildApis(songId, q)
var promises = []
for (var i = 0; i < apis.length; i++) {
(function(api) {
promises.push(
httpGet(api.url, api.headers, 8000).then(function(res) {
var url = api.extract(res)
if (url) {
console.log('[Koneko QQ音乐] ' + api.name + ' 成功')
return { name: api.name, url: url }
}
throw new Error(api.name + ' 无有效URL')
}).catch(function(err) {
console.error('[Koneko QQ音乐] ' + api.name + ' 失败: ' + err.message)
throw err
})
)
})(apis[i])
}
return Promise.all(promises.map(function(p) {
return p.then(function(v) { return { status: 'fulfilled', value: v } }).catch(function(e) { return { status: 'rejected', reason: e } })
})).then(function(results) {
for (var i = 0; i < results.length; i++) {
if (results[i].status === 'fulfilled') return results[i].value.url
}
console.error('[Koneko QQ音乐] 所有API均失败')
return ''
})
}
var leaderboard = { getList: function() { return Promise.resolve([]) } }
var songList = {
getListDetail: function(id, page, limit) {
return Promise.resolve({ list: [], page: page || 1, limit: limit || 30, total: 0, source: 'tx', info: { name: '', img: '', desc: '', author: '' } })
}
}
var singer = { getInfo: function(id) { return Promise.resolve(null) } }
var album = {
getListDetail: function(id) {
return Promise.resolve({ list: [], page: 1, limit: 1000, total: 0, source: 'tx', info: { name: '', img: '', desc: '', author: '' } })
},
search: function(str, page, limit) { return Promise.resolve([]) }
}
function getLyric(id) { return Promise.resolve('') }
function getPic(songId) { return Promise.resolve('') }
function musicDetail(id) { return Promise.resolve(null) }
function musicInfo(id) { return Promise.resolve(null) }
var pluginInfo = {
info: { id: 'koneko_tx', name: 'QQ音乐 - Koneko', version: '0.0.2', description: 'QQ音乐聚合音源插件官方搜索+多API音源自动测速容灾切换' },
env: [{ key: 'ceru_key', name: '聆澜API Key', description: '聆澜音源API密钥留空则跳过聆澜音源' }],
ext: [],
quality: [
{ name: '标准音质', ui: '标', id: 'standard' },
{ name: '高品音质', ui: 'HQ', id: 'exhigh' },
{ name: '无损音质', ui: 'SQ', id: 'lossless' },
{ name: 'Hi-Res', ui: 'HR', id: 'hires' }
],
supportFunc: []
}
module.exports = {
musicSearch: musicSearch,
tipSearch: tipSearch,
leaderboard: leaderboard,
songList: songList,
hotSearch: hotSearch,
singer: singer,
album: album,
getLyric: getLyric,
getPic: getPic,
getUrl: getUrl,
musicDetail: musicDetail,
musicInfo: musicInfo,
pluginInfo: pluginInfo
}