一、思路概述
1、通过node
内置的http/https
模块获取指定网站html
2、通过第三方cheerio
模块提取html中的所有img标签,所以运行前不要忘记
npm install cheerio
3、使用http/https
请求所有img标签中的图片地址,并通过node
内置的fs
模块将返回的图片数据存储到文件系统中
二、源码
本例展示如何爬取w3cschool首页图片
// 用于发送http请求
const https = require('https')
const http = require('http')
// 用于提取网页中的img标签
const cheerio = require('cheerio')
// 用于将http响应中的数据写到文件中
const fs = require('fs')
// 用于获取系统文件分隔符
const path = require('path')
const sep = path.sep
// 用于存储图片和网页的文件夹路径
const imgDir = `${__dirname}${sep}imgs${sep}`
const pageDir = `${__dirname}${sep}pages${sep}`
// https协议名
const HTTPS = 'https:'
// 若文件夹不存在则创建
for (const dir of [imgDir, pageDir]) {
if (!fs.existsSync(dir)) {
console.log('文件夹(%s)不存在,即将为您创建', dir)
fs.mkdirSync(dir)
}
}
// const url = 'http://gee2dan.com/'
const url = 'https://www.w3cschool.cn/'
// 下载中的图片数量
let downloadingCount = 0
downloadImgsOn(url)
// 下载指定网站包含的图片
function downloadImgsOn(url) {
// URL作为options
const options = new URL(url);
// 获取协议
const protocol = options.protocol
// 根据协议选择发送请求的模块
const _http = protocol === HTTPS ? https : http
// 发送请求
const req = _http.request(options, (res) => {
// 用于存储返回的html数据
let htmlData = ''
res.on('data', (chunk) => {
htmlData += chunk.toString('utf8')
})
res.on('end', () => {
// 将html数据存储到文件中,可用于人工校验
const htmlFileName = `${pageDir}result.html`
fs.writeFile(htmlFileName, htmlData, () => {
console.log('页面(%s)读取完毕,已保存至(%s)', url, htmlFileName)
})
// 将html信息转换为类jq对象
const $ = cheerio.load(htmlData)
const imgs = $('img')
// 用于保存需要下载的图片url,去除重复的图片url
const imgUrlSet = new Set()
imgs.each((index, img) => {
// 获取图片url
let imgUrl = img.attribs.src
// 将不完整的图片url转完成完整的图片url
if (imgUrl.startsWith('//')) {
imgUrl = protocol + imgUrl
} else if (imgUrl.startsWith('/')) {
imgUrl = url + imgUrl
}
imgUrlSet.add(imgUrl)
})
console.log('获取图片url共%s个', imgUrlSet.size)
// 下载imgUrlSet中包含的图片s
for (const imgUrl of imgUrlSet) {
downloadImg(imgUrl)
}
})
})
req.on('error', (err) => {
console.error(err)
})
req.end();
}
/**
* 打印当前正在下载的图片数
*/
function printDownloadingCount() {
console.log('当前下载中的图片有%s个', downloadingCount)
}
/**
* 下载指定url对应的图片
* @param {*} imgUrl 目标图片url
* @param {*} maxRetry 下载失败重试次数
* @param {*} timeout 超时时间毫秒数
*/
function downloadImg(imgUrl, maxRetry = 10, timeout = 10000) {
/**
* 用于下载失败后重试
*/
function retry() {
if (maxRetry) {
console.log('(%s)剩余重试次数:%s,即将重试', imgUrl, maxRetry);
downloadImg(imgUrl, maxRetry - 1);
} else {
console.log('(%s)下载彻底失败', imgUrl)
}
}
// URL作为options
const options = new URL(imgUrl);
// 根据协议选择发送请求的模块
const _http = options.protocol === HTTPS ? https : http
// 从url中提取文件名
const matches = imgUrl.match(/(?<=.*\/)[^\/\?]+(?=\?|$)/)
const fileName = matches && matches[0]
// 请求关闭时是否需要重新请求
let retryFlag = false
const req = _http.request(options, (res) => {
console.log('开始下载图片(%s)', imgUrl)
downloadingCount += 1
printDownloadingCount()
// 判断数据是否为图片类型,仅保存图片类型
const contentType = res.headers['content-type']
if (contentType.startsWith('image')) {
// 存储图片数据到内存中
const chunks = []
res.on('data', (chunk) => {
chunks.push(chunk)
})
// req.on('abort') 中相同的操作也可以写在 res.on('aborted') 中
// res.on('aborted', () => {})
res.on('end', () => {
downloadingCount -= 1
printDownloadingCount()
// 若响应正常结束,将内存中的数据写入到文件中
if (res.complete) {
console.log('图片(%s)下载完成', imgUrl)
write(imgDir + fileName, chunks, 0)
} else {
console.log('(%s)下载结束但未完成', imgUrl)
}
})
}
})
req.on('error', (err) => {
console.error(err)
retryFlag = true
})
req.on('abort', () => {
console.log('下载(%s)被中断', imgUrl)
retryFlag = true
})
req.on('close', () => {
if (retryFlag) {
retry()
}
})
// 如果超时则中止当前请求
req.setTimeout(timeout, () => {
console.log('下载(%s)超时', imgUrl)
req.abort()
})
req.end()
}
/**
* 将数据块数组chunks中第index个数据块写入到distFileName对应文件的末尾
* @param {*} distFileName 数据将写入的文件名
* @param {*} chunks 图片数据块数组
* @param {*} index 写入数据块的索引
*/
function write(distFileName, chunks, index) {
if (index === 0) {
var i = 0
// 判断文件是否重名,若重名则重新生成带序号的文件名
let tmpFileName = distFileName
while (fs.existsSync(tmpFileName)) {
tmpFileName = distFileName.replace(new RegExp(`^(.*?)([^${sep}\\.]+)(\\..*|$)`), `$1$2_${i}$3`)
i += 1
}
distFileName = tmpFileName
}
// 获取图片数据块依次写入文件
const chunk = chunks[index]
if (chunk) {
// 异步、递归
fs.appendFile(distFileName, chunk, () => {
write(distFileName, chunks, index + 1)
})
} else {
console.log('文件(%s)写入完毕', distFileName)
}
}
三、注意事项
1、超时问题
下载图片过程中偶尔会出现某个url长时间不响应的情况,而http/https
模块不支持在请求超时时返回或抛出异常,需要我们手动调用request.abort
方法来中止请求
应用中可以结合request.setTimeout
方法实现请求的超时控制,例如:
const http = require('http')
options = {
// ...
}
const req = http.request(options, (res) => {
// ...
}
req.setTimeout(1000, () => {
req.abort()
})
2、重新请求
下载图片的过程中难免发生异常。对此,我们可以引入重新请求的机制,即在超时、请求异常等情况发生时,再次发起请求,以此提高爬取图片的成功率。
从章节二源码中可以看到,我在事件req.on('abort')
和req.on('error')
皆执行了retryFlag = true
来设置 重传标志位,并在req.on('close')
事件中检查 重传标志位 以确定是否要发起重新请求。
那么为什么这样做可以满足重新请求的需求?这要从事件的触发顺序说起。
通过官方文档 / 中文文档可以了解到,事件触发顺序分为以下几种情况:
a 成功请求
成功的请求中,会按以下顺序触发以下事件:
'socket' 事件
-
'response' 事件
-
res
对象上任意次数的 'data' 事件(如果响应主体为空,则根本不会触发 'data' 事件,例如在大多数重定向中) -
res
对象上的 'end' 事件
-
'close' 事件
b 连接错误
如果出现连接错误,则触发以下事件:
- 'socket' 事件
- 'error' 事件
- 'close' 事件
c 未连接成功时中止请求
如果在连接成功之前调用 req.abort(),则按以下顺序触发以下事件:
- 'socket' 事件
在这里调用 req.abort()
- 'abort' 事件
- 'error' 事件并带上错误信息 'Error: socket hang up' 和错误码 'ECONNRESET'
- 'close' 事件
d 在响应阶段中止请求
如果在响应被接收之后调用 req.abort(),则按以下顺序触发以下事件:
'socket' 事件
-
'response' 事件
-
res
对象上任意次数的 'data' 事件
-
在这里调用 req.abort()
- 'abort' 事件
-
res
对象上的 'aborted' 事件 - 'close' 事件
-
res
对象上的 'end' 事件 -
res
对象上的 'close' 事件
除了情况 a 外,情况 bcd 都属于下载失败的情况,需要重新发起请求。
通过观察不难发现,b 区别于 a 的事件有 'error' 事件,c 区别于 a 的事件包括 'abort' 事件和 'error' 事件,而 d 区别于 a 的事件中也包括 'abort' 事件。
所以只要对 req.on('abort')
和req.on('error')
这对组合进行处理,就可以覆盖 bcd 三种下载失败的情况。当然,组合的情况不唯一,例如 res.on('aborted')
和req.on('error')
同样可以满足需求。
3、图片去重与重名处理
a 图片去重
一个html可能包含若干src相同的img标签,可以在提取img的src时进行归一化处理,统一格式后再使用Set去重。去重后可以减少请求数量,提高爬取效率。
b 重名处理
应当考虑存在 尽管src不同,但文件名相同 的情况,若不处理,同名图片在写入时会发生覆盖,引起图片乱码和丢失。所以写入前应当检查是否存在同名文件,若存在同名文件,则在原文件名后增加唯一的序号,再进行写入。以此保证爬取图片的完整性
谢天谢地 node是单线程的
4、文件写入
从章节二可以看到,请求获取的图片数据首先存放在chunks
数组中,响应正常结束后才将chunks
中的数据写入到文件中。
写入操作分为 同步 和 异步,若采用 同步 方式写入,代码如下
function write(distFileName, chunks) {
// ...
// 获取图片数据块依次写入文件
for (const chunk of chunks) {
fs.appendFileSync(distFileName, chunk)
}
}
章节二中采用的是 异步 方式。若采用类似于 同步 方式的代码 进行异步写入,则无法保证每一个数据块的写入顺序。故本人采用了一套略微曲折的写法,保证了异步写入的有序性
本以为大费周章的采用异步写入,在效率上会优于同步方式。but经过测试,并没有发现同步与异步的明显差别,看来我还是太年轻了
四、运行结果
完