310 lines
13 KiB
JavaScript
310 lines
13 KiB
JavaScript
var https = require('https');
|
||
var http = require('http');
|
||
var crypto = require('crypto');
|
||
var querystring = require('querystring');
|
||
|
||
// ===== 通用HTTP请求 =====
|
||
function doRequest(protocol, options, postData) {
|
||
return new Promise(function(resolve, reject) {
|
||
var client = protocol === 'https' ? https : http;
|
||
var req = client.request(options, function(res) {
|
||
var chunks = [];
|
||
res.on('data', function(chunk) { chunks.push(chunk); });
|
||
res.on('end', function() {
|
||
var buf = Buffer.concat(chunks);
|
||
var body;
|
||
try {
|
||
body = JSON.parse(buf.toString('utf8'));
|
||
} catch (e) {
|
||
body = buf.toString('utf8');
|
||
}
|
||
resolve({ body: body, statusCode: res.statusCode, headers: res.headers });
|
||
});
|
||
});
|
||
req.on('error', function(err) { reject(err); });
|
||
req.setTimeout(15000, function() { req.destroy(); reject(new Error('timeout')); });
|
||
if (postData) req.write(postData);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
function requestUrl(urlStr, method, headers, body) {
|
||
var parsed = new URL(urlStr);
|
||
var protocol = parsed.protocol.replace(':', '');
|
||
var options = {
|
||
hostname: parsed.hostname,
|
||
port: parsed.port || (protocol === 'https' ? 443 : 80),
|
||
path: parsed.pathname + parsed.search,
|
||
method: method || 'GET',
|
||
headers: headers || {}
|
||
};
|
||
var postData = null;
|
||
if (body) {
|
||
if (typeof body === 'string') {
|
||
postData = Buffer.from(body, 'utf8');
|
||
} else if (Buffer.isBuffer(body)) {
|
||
postData = body;
|
||
} else {
|
||
postData = Buffer.from(JSON.stringify(body), 'utf8');
|
||
if (!options.headers['Content-Type']) options.headers['Content-Type'] = 'application/json';
|
||
}
|
||
if (postData) options.headers['Content-Length'] = postData.length;
|
||
}
|
||
return doRequest(protocol, options, postData);
|
||
}
|
||
|
||
// ===== QQ音乐zzcSign =====
|
||
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 hashSHA1(text) {
|
||
return crypto.createHash('sha1').update(Buffer.from(text, 'utf-8')).digest().toString('hex').toUpperCase();
|
||
}
|
||
function pickHashByIdx(hash, indexes) {
|
||
var out = '';
|
||
for (var i = 0; i < indexes.length; i++) out += hash[indexes[i]];
|
||
return out;
|
||
}
|
||
function base64Encode(data) {
|
||
var buf;
|
||
if (typeof data === 'string') buf = Buffer.from(data);
|
||
else if (Array.isArray(data)) buf = Buffer.from(data);
|
||
else buf = data;
|
||
return buf.toString('base64').replace(/[\\/+=]/g, '');
|
||
}
|
||
function zzcSign(text) {
|
||
var hash = hashSHA1(text);
|
||
var part1 = pickHashByIdx(hash, PART_1_INDEXES);
|
||
var part2 = pickHashByIdx(hash, PART_2_INDEXES);
|
||
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 = base64Encode(part3).replace(/[\\/+=]/g, '');
|
||
return ('zzc' + part1 + b64Part + part2).toLowerCase();
|
||
}
|
||
function randomInt(min, max) {
|
||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||
}
|
||
function getSearchId() {
|
||
var e = randomInt(1, 20);
|
||
var t = Number(e * Number('18014398509481984').toFixed());
|
||
var n = randomInt(0, 4194304) * 4294967296;
|
||
var a = Date.now();
|
||
var r = Math.round(a * 1000) % (24 * 60 * 60 * 1000);
|
||
return String(t + n + r);
|
||
}
|
||
function signRequest(data) {
|
||
var sign = zzcSign(JSON.stringify(data));
|
||
return requestUrl('https://u.y.qq.com/cgi-bin/musics.fcg?sign=' + encodeURIComponent(sign), 'POST', {
|
||
'User-Agent': 'QQMusic 14090508(android 12)',
|
||
'Content-Type': 'application/json'
|
||
}, JSON.stringify(data));
|
||
}
|
||
|
||
function formatPlayTime(seconds) {
|
||
if (!seconds) return '00:00';
|
||
var m = Math.floor(seconds / 60);
|
||
var s = Math.floor(seconds % 60);
|
||
return (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s);
|
||
}
|
||
|
||
// ===== 搜索实现 =====
|
||
function musicSearch(str, page, limit) {
|
||
var body = {
|
||
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: getSearchId(), 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(body).then(function(res) {
|
||
if (res.body && res.body.code === 0 && res.body.req && res.body.req.code === 0) return res.body.req.data;
|
||
throw new Error('tx search failed');
|
||
});
|
||
}
|
||
|
||
function handleSearchResult(rawList) {
|
||
var list = [];
|
||
for (var i = 0; i < rawList.length; i++) {
|
||
var item = rawList[i];
|
||
if (!item.file || !item.file.media_mid) continue;
|
||
var types = [];
|
||
var _types = {};
|
||
var file = item.file;
|
||
if (file.size_128mp3 !== 0) { types.push({ type: '128k', size: '' }); _types['128k'] = { size: '' }; }
|
||
if (file.size_320mp3 !== 0) { types.push({ type: '320k', size: '' }); _types['320k'] = { size: '' }; }
|
||
if (file.size_flac !== 0) { types.push({ type: 'flac', size: '' }); _types.flac = { size: '' }; }
|
||
if (file.size_hires !== 0) { types.push({ type: 'hires', size: '' }); _types.hires = { size: '' }; }
|
||
var albumId = '';
|
||
var albumName = '';
|
||
if (item.album) { albumName = item.album.name; albumId = item.album.mid; }
|
||
var singerName = '';
|
||
if (item.singer && item.singer.length) {
|
||
var ns = [];
|
||
for (var j = 0; j < item.singer.length; j++) ns.push(item.singer[j].name);
|
||
singerName = ns.join('、');
|
||
}
|
||
list.push({
|
||
singer: singerName,
|
||
name: item.name + (item.title_extra || ''),
|
||
albumName: albumName,
|
||
albumId: albumId,
|
||
source: 'tx',
|
||
interval: formatPlayTime(item.interval),
|
||
songmid: item.mid,
|
||
songId: item.id,
|
||
img: albumId ? 'https://y.gtimg.cn/music/photo_new/T002R500x500M000' + albumId + '.jpg' : (item.singer && item.singer.length ? 'https://y.gtimg.cn/music/photo_new/T001R500x500M000' + item.singer[0].mid + '.jpg' : ''),
|
||
lrc: null,
|
||
types: types, _types: _types, typeUrl: {}
|
||
});
|
||
}
|
||
return list;
|
||
}
|
||
|
||
function search(str, page, limit, retryNum) {
|
||
if (retryNum === undefined) retryNum = 0;
|
||
if (++retryNum > 3) return Promise.reject(new Error('try max num'));
|
||
if (!limit) limit = 30;
|
||
return musicSearch(str, page, limit).then(function(data) {
|
||
if (!data || !data.body || !data.body.item_song) return search(str, page, limit, retryNum);
|
||
var list = handleSearchResult(data.body.item_song);
|
||
var meta = data.meta || {};
|
||
var total = meta.estimate_sum || 0;
|
||
return { list: list, allPage: Math.ceil(total / limit), limit: limit, total: total, source: 'tx' };
|
||
});
|
||
}
|
||
|
||
// ===== 播放链接 =====
|
||
function getUrl(songInfo, type) {
|
||
var q = type || '128k';
|
||
var songId = songInfo.songmid || songInfo.id || '';
|
||
var ceruKey = process.env.ceru_key || '';
|
||
var headers = { 'User-Agent': 'QZMusic/2.0' };
|
||
if (ceruKey) headers['X-API-Key'] = ceruKey;
|
||
return requestUrl('https://source.shiqianjiang.cn/api/music/url?source=tx&songId=' + encodeURIComponent(songId) + '&quality=' + encodeURIComponent(q), 'GET', headers)
|
||
.then(function(res) {
|
||
if (res.body && typeof res.body === 'object' && res.body.url) return res.body.url;
|
||
if (res.body && typeof res.body === 'object' && res.body.data && res.body.data.url) return res.body.data.url;
|
||
if (typeof res.body === 'string' && res.body.indexOf('http') === 0) return res.body;
|
||
return '';
|
||
}).catch(function() { return ''; });
|
||
}
|
||
|
||
// ===== 封面图 =====
|
||
function getPic(songInfo) {
|
||
if (songInfo.img) return Promise.resolve(songInfo.img);
|
||
var albumId = songInfo.albumId || '';
|
||
if (albumId) return Promise.resolve('https://y.gtimg.cn/music/photo_new/T002R500x500M000' + albumId + '.jpg');
|
||
return Promise.resolve('');
|
||
}
|
||
|
||
// ===== 歌词 =====
|
||
function getLyric(songInfo) {
|
||
var songmid = songInfo.songmid || '';
|
||
return requestUrl('https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg?format=json&nobase64=1&songmid=' + encodeURIComponent(songmid), 'GET', {
|
||
'User-Agent': 'Mozilla/5.0 (Linux; Android 10)',
|
||
'Referer': 'https://y.qq.com/portal/player.html'
|
||
}).then(function(res) {
|
||
if (typeof res.body === 'string') {
|
||
try { var obj = JSON.parse(res.body.replace(/^MusicJsonCallback\(/, '').replace(/\)$/, '')); return obj.lyric || ''; } catch(e) {}
|
||
}
|
||
if (res.body && res.body.lyric) return res.body.lyric;
|
||
return '';
|
||
}).catch(function() { return ''; });
|
||
}
|
||
|
||
// ===== 音乐详情 =====
|
||
function musicDetail(songmid) {
|
||
var data = {
|
||
comm: { ct: '11', cv: '14090508', v: '14090508', tmeAppID: 'qqmusic' },
|
||
req: { module: 'music.pf_song_detail_svr', method: 'get_song_detail_yqq', param: { song_mid: songmid } }
|
||
};
|
||
return signRequest(data).then(function(res) {
|
||
if (res.body && res.body.req && res.body.req.data && res.body.req.data.info && res.body.req.data.info.track_info) {
|
||
var t = res.body.req.data.info.track_info;
|
||
var albumId = t.album ? t.album.mid : '';
|
||
var singerName = '';
|
||
if (t.singer && t.singer.length) { var ns = []; for (var j = 0; j < t.singer.length; j++) ns.push(t.singer[j].name); singerName = ns.join('、'); }
|
||
return {
|
||
singer: singerName, name: t.name,
|
||
albumName: t.album ? t.album.name : '', albumId: albumId,
|
||
source: 'tx', interval: formatPlayTime(t.interval),
|
||
songmid: t.mid, songId: t.id,
|
||
img: albumId ? 'https://y.gtimg.cn/music/photo_new/T002R500x500M000' + albumId + '.jpg' : '',
|
||
lrc: null, types: [], _types: {}, typeUrl: {}
|
||
};
|
||
}
|
||
return null;
|
||
}).catch(function() { return null; });
|
||
}
|
||
|
||
function musicInfo(songmid) { return musicDetail(songmid); }
|
||
|
||
// ===== 热搜 =====
|
||
function hotSearch() {
|
||
var data = {
|
||
comm: { ct: '19', cv: '1803', guid: '0', patch: '118', psrf_access_token_expiresAt: 0, psrf_qqaccess_token: '', psrf_qqopenid: '', psrf_qqunionid: '', tmeAppID: 'qqmusic', tmeLoginType: 0, uin: '0', wid: '0' },
|
||
hotkey: { method: 'GetHotkeyForQQMusicPC', module: 'tencent_musicsoso_hotkey.HotkeyService', param: { search_id: '', uin: 0 } }
|
||
};
|
||
return requestUrl('https://u.y.qq.com/cgi-bin/musicu.fcg', 'POST', {
|
||
'User-Agent': 'Mozilla/5.0 (Linux; Android 10)',
|
||
'Referer': 'https://y.qq.com/portal/player.html',
|
||
'Content-Type': 'application/json'
|
||
}, JSON.stringify(data)).then(function(res) {
|
||
var list = [];
|
||
if (res.body && res.body.hotkey && res.body.hotkey.data && res.body.hotkey.data.vec_hotkey) {
|
||
var arr = res.body.hotkey.data.vec_hotkey;
|
||
for (var i = 0; i < arr.length; i++) list.push(arr[i].query);
|
||
}
|
||
return { source: 'tx', list: list };
|
||
}).catch(function() { return { source: 'tx', list: [] }; });
|
||
}
|
||
|
||
// ===== 搜索建议 =====
|
||
function tipSearch(str) {
|
||
return Promise.resolve({ order: [], songs: [], artists: [], albums: [], playlists: [] });
|
||
}
|
||
|
||
// ===== 榜单 =====
|
||
function leaderboard() {
|
||
return Promise.resolve([]);
|
||
}
|
||
|
||
// ===== 歌单 =====
|
||
function songList() {
|
||
return Promise.resolve({ list: [], total: 0, page: 1, source: 'tx' });
|
||
}
|
||
|
||
// ===== 歌手 =====
|
||
function singer() {
|
||
return Promise.resolve({ source: 'tx', list: [], total: 0 });
|
||
}
|
||
|
||
// ===== 专辑 =====
|
||
function album() {
|
||
return Promise.resolve({ source: 'tx', list: [], total: 0 });
|
||
}
|
||
|
||
// ===== 插件信息 =====
|
||
module.exports = {
|
||
info: { id: 'koneko_ceru_tx', name: 'QQ音乐 - Koneko 聆澜', version: '0.0.3', source: 'tx', description: 'QQ音乐音源,基于 CeruMusic musicSdk 搜索 + Ceru 播放接口' },
|
||
ext: [],
|
||
env: [{ key: 'ceru_key', name: 'Ceru API Key', description: '聆澜播放接口密钥' }],
|
||
quality: [
|
||
{ name: '标准', ui: '标', id: '128k' },
|
||
{ name: '高品', ui: 'HQ', id: '320k' },
|
||
{ name: '无损', ui: 'SQ', id: 'flac' }
|
||
],
|
||
supportFunc: ['musicSearch', 'tipSearch', 'hotSearch', 'leaderboard', 'songList', 'singer', 'album', 'getLyric', 'getPic', 'getUrl', 'musicDetail', 'musicInfo'],
|
||
musicSearch: { search: search },
|
||
tipSearch: { search: tipSearch },
|
||
hotSearch: { getList: hotSearch },
|
||
leaderboard: { getBoards: leaderboard },
|
||
songList: { search: songList, getTags: function() { return Promise.resolve({ tags: [], hotTag: [], source: 'tx' }); }, getList: songList },
|
||
singer: { getInfo: singer, getSongList: singer, getAlbumList: singer },
|
||
album: { getInfo: album, getSongList: album },
|
||
getLyric: getLyric,
|
||
getPic: getPic,
|
||
getUrl: getUrl,
|
||
musicDetail: musicDetail,
|
||
musicInfo: musicInfo
|
||
};
|