最近忙着组NAS,一直没有时间更新。昨天忘了从哪理翻出了喜马拉雅的API,顺藤摸瓜就花了一个小时写了这个爬虫。大概你们觉得听喜马拉雅直接开web或者app就能满足了呀,合并爬硬盘到本地呢?如果我说,喜马拉雅会下架一些我喜欢的音频,然后再也听不到了呢?放在自己的盘里,虽然保不准哪天盘烧了,安全性不高,但是至少在盘没烧之前,控制权在自己手上呀。
第一步:找API本来我是想找人人影视的api的,想搭好NAS就下载订阅的美剧,发现加密方式没弄懂,哪位小伙伴知道的麻烦教教我哈。所以想着要不先爬取喜马拉雅的音频吧,反正无聊的时候用蓝牙音箱听听也不错。搜了github找到一些爬虫,分析代码找到一个音频真实地址的API。但是这远远不够啊,我要下载的是整个专辑。
在检查面板里翻了一圈,在很多个API里面找到了两个适合的API。
// config/index.js
module.exports = {
tracks: "http://www.ximalaya.com/tracks/<%=tracksID%>.json",
getTracksList: "http://www.ximalaya.com/revision/album/getTracksList?albumId=<%=albumId%>&pageNum=<%=pageNum%>",
album: "http://www.ximalaya.com/revision/album?albumId=<%=albumId%>"
}
API中我使用了Lodash模板格式替换了参数,方便传参。
于是开始有了一个酱紫的思路。
找到想要的专辑->获取整个专辑下的所有的音频的ID->获取每一个音频ID的真实链接->保存成aria2批量下载文件->使用aria2下载
有了思路,还需要合适的工具,node有一些封装好的模块方便我们使用,当实在找不到合适的模块的时候,我们还能自己写模块。
因为这个爬虫不需要解析HTML,服务器返回的数据是json,所以我们只需要两个基础模块:request
和lodash
。
然而request
是callback回调,然而我想用Promise方式,自己封装一个。
// request.js
const request = require('request');
const isFunction = require('lodash/isFunction');
const fs = require('fs');
async function get(options, callback) {
const { pipe, hiden, time, size, readable, ...opts } = options;
const start = time !== undefined ? time.start : new Date().valueOf() / 1000;
let read = options.read || 0;
let response = 0;
let total = 0;
const value = await new Promise((resolve) => {
let buffer = Buffer.alloc(0);
const res = request(opts, (error, resp, body) => {
resolve({ error, resp, body, read, bufferBody: buffer.toString("utf8") });
}).on('response', (resp) => {
if (resp.headers['content-length'] || size) {
response = parseInt(resp.headers['content-length'] || size || 0, 10);
}
}).on('data', (data) => {
read += data.length;
if (readable) {
buffer = Buffer.concat([buffer, data]);
}
total = ((size !== undefined || response === undefined) && size >= read) ? size : response || read + 1;
if (isFunction(callback)) {
callback({
completed: read,
total,
hiden,
time: { start },
status: {
down: '正在下载...',
end: '完成\n'
}
});
}
});
// 如果 pipe参数存在,则下载到指定路径
if (pipe) {
res.pipe(fs.createWriteStream(pipe.out || './'));
}
});
return value;
}
module.exports = get;
emmmm......我在promise里面加了个callback,用于返回下载进度,方便以后接入进度条什么的。
哦,差点忘了,我们还需要安装aria2,linux的用户包管理安装,win用户自行下载exe文件后配置环境变量。要是实在不会,百度一下你知道。
这样工具都准备好了,接下来就是写代码的时间了。
写代码先把封装好的模块和可能需要用的模块统一写在一个文件中,方便管理。
// commonModules.js
const fs = require('fs');
const path = require('path');
const request = require('./request');
module.exports = {
fs,
path,
request,
};
获取专辑下所有的音频
// index.js
const getTracksList = async (albumId, arr = [], pageNum = 1) => {
const opts = {
uri: template(cfg.getTracksList)({ albumId, pageNum })
}
const body = JSON.parse((await request(opts)).body);
arr = concat(arr, body.data.tracks);
if (!(body.data.tracks.length < 30) || pageNum * 30 === body.data.trackTotalCount) {
return await getTracksList(albumId, arr, pageNum + 1);
} else {
return arr;
}
}
其中cfg
这个是之前的API配置文件因为API每次返回的数据默认最大30条,所以直接在代码里写30了,这里返回专辑所以的音频的url,url里面有我们想要的ID。
获取所以的音频的真实链接
const getTracks = async (list, out = "./", str = "") => {
const item = list.splice(0, 1)[0];
const data = JSON.parse((await request({
uri: template(cfg.tracks)({ tracksID: path.basename(item.url) }),
})).body);
str += `${data.play_path_64}\n\tout=${trim(item.title)}.m4a\n\tdir=${out}\n`
if (list.length) {
return await getTracks(list, out, str);
} else {
return str;
}
}
返回的是一个字符串,按aria2批量下载文件的格式拼接的,最后要保存到文件中供aria2使用。
最后导出模块
module.exports = async (albumId, path = "./list.txt", out) => {
const list = await getTracksList(albumId);
fs.writeFileSync(path, await getTracks(list, out));
}
完整的代码如下:
const template = require("lodash/template");
const concat = require("lodash/concat");
const trim = require("lodash/trim");
const cfg = require("./config/index");
const { path, request, fs } = require('./tools/commonModules');
const getTracksList = async (albumId, arr = [], pageNum = 1) => {
const opts = {
uri: template(cfg.getTracksList)({ albumId, pageNum })
}
const body = JSON.parse((await request(opts)).body);
arr = concat(arr, body.data.tracks);
if (!(body.data.tracks.length < 30) || pageNum * 30 === body.data.trackTotalCount) {
return await getTracksList(albumId, arr, pageNum + 1);
} else {
return arr;
}
}
const getTracks = async (list, out = "./", str = "") => {
const item = list.splice(0, 1)[0];
const data = JSON.parse((await request({
uri: template(cfg.tracks)({ tracksID: path.basename(item.url) }),
})).body);
str += `${data.play_path_64}\n\tout=${trim(item.title)}.m4a\n\tdir=${out}\n`
if (list.length) {
return await getTracks(list, out, str);
} else {
return str;
}
}
module.exports = async (albumId, path = "./list.txt", out) => {
const list = await getTracksList(albumId);
fs.writeFileSync(path, await getTracks(list, out));
}
整个目录结构
测试例子就不写了,emmmm.......我就是这么懒,来打我呀hiahiahiahiahia