这里有一个并发的机制,用 while
和 Promise.all
其实就很好的控制。也可以使用 chunk
(通过 reduce
、slice
可以非常简单的实现)函数分割成 4 个一组的二维数组,然后通过 Promise.all 执行。当然用 stream
编程的模式其实也可以控制,不过相对较难一些,原理就是限定并发量,当并发量超过的时候,通过延迟调用 done 方法控制下一个任务的进入。在我的个人网站有一期 stream
精讲有说明如何去做!并且还实现了一个简单的 RPC 调用。
spinner
是控制台的输出器,用来显示进度的,showNum
就是计算出来完成了多少的进度。
async function downAllText(spinner, crawl, opts) {
const { path, concurrence, waitTime } = opts
const chapters = require(resolve(path, 'chapters.json'))
let index = 0
while (index < chapters.length) {
const currentTick = chapters.slice(index, index + concurrence) // 每次取 concurrence 个 数据构建 promise 通过 Promise.all 同时执行。
await Promise.all(
currentTick.map((chapter, i) => {
return downloadText(chapter, crawl, index + i, opts)
}) // 构建 promise 数组
)
// 等待事件
await sleep(waitTime)
let showNum = Math.floor(
((chapters.length - index) * 100) / chapters.length
)
// 设置终端文字
spinner.color = 'red'
spinner.text = '爬取中 ' + (100 - showNum) + '% -> ' + chapters[index].title
index += concurrence
}
}
在最后一定要使用 spinner.stop
让其停止,要不然有的时候会让控制台一直在转,最终的 download 会像下面的代码所示。
async function download(url, path, crawl) {
const opts = Object.assign(
{},
{
path,
concurrence: 4,
waitTime: 0,
charset: 'utf-8'
},
crawl.opts
) // 设置默认初始选项,通过 assign 构建可被覆盖的默认值
const spinner = ora('开始下载').start() // 提示信息开始旋转
await ensureSavaPath(resolve(path, 'text')) // 确保文件存在
spinner.color = 'yellow'
spinner.text = '开始爬取章节' // 改变文字与颜色
await downloadChapter(url, crawl, opts) // 下载章节
spinner.color = 'blue'
spinner.text = '开始爬取内容' // 改变文字与颜色
await downAllText(spinner, crawl, opts) // 下载文章内容
spinner.succeed(`爬取完成`) // 改变文字与停止
spinner.stop()
}
这里使用正常的方式编程即可,用 function 的原因是为了保留 this 指向。 $ 是 cheerio 提供的选择器,它跟 jquery 的使用基本相同,具体请查阅 文档 。
// 笔趣阁爬取规则
const getDataFromYbdu = {
opts: {
charset: 'gbk' // 字符集,部分网站 gbk 编码,下载之后是乱码
},
chapter($, url) {
// 章节获取规则
const datas = []
$('.mulu_list li').each(function(i, ele) {
// 选择每一个连接
const self = $(this)
const title = self.text().replace(/\s/g, '') // 得到文本内容
const chapter_url = self.find('a').attr('href') // 得到 dom 下面 a 标签 herf 属性
datas.push({
title, // 标题
url: urljoin(url, chapter_url) // 内容页面的网址
})
})
return datas
},
text($) {
// 对内容页面的网址的爬取规则
const trim = sourceString => {
const dels = [
'加入书签',
'加入书架',
'推荐投票',
'返回书页',
'上一页',
'返回目录',
'下一页',
/\s+/gi,
/(\-)*/gi
]
dels.forEach(delString => {
// 纯函数
sourceString = sourceString.replace(delString, '') // 删除广告词
})
return sourceString
}
const sourceString = $('#htmlContent').text() // 获取内容
return trim(sourceString)
}
}
然后我们测试一下
download(
'https://www.ybdu.com/xiaoshuo/0/910/', // 小说目录页
resolve(__dirname, '../jstm'), // 保存地址
getDataFromYbdu // 爬取规则
).catch(console.error)
process.argv
可以取到运行命令,去掉前两项是 node
跟 download.js
,网址末尾的 /
不能丢,丢了会有个 301
跳转,暂时还没做处理。
const args = process.argv.slice(2)
download(args[0], resolve(process.pwd(), args[0]), getDataFromYbdu).catch(
console.error
)
node download.js https://www.ybdu.com/xiaoshuo/0/910/ ./jxtm
假如你需要更多参数解析功能,可以使用微型库 minimist
,假如你的是大型命令行程序,像 heroku
那样的,可以使用 oclif