一、思路概述
1、通過(guò)node內(nèi)置的http/https模塊獲取指定網(wǎng)站html
2、通過(guò)第三方cheerio模塊提取html中的所有img標(biāo)簽,所以運(yùn)行前不要忘記
npm install cheerio
3、使用http/https請(qǐng)求所有img標(biāo)簽中的圖片地址,并通過(guò)node內(nèi)置的fs模塊將返回的圖片數(shù)據(jù)存儲(chǔ)到文件系統(tǒng)中
二、源碼
本例展示如何爬取w3cschool首頁(yè)圖片
// 用于發(fā)送http請(qǐng)求
const https = require('https')
const http = require('http')
// 用于提取網(wǎng)頁(yè)中的img標(biāo)簽
const cheerio = require('cheerio')
// 用于將http響應(yīng)中的數(shù)據(jù)寫(xiě)到文件中
const fs = require('fs')
// 用于獲取系統(tǒng)文件分隔符
const path = require('path')
const sep = path.sep
// 用于存儲(chǔ)圖片和網(wǎng)頁(yè)的文件夾路徑
const imgDir = `${__dirname}${sep}imgs${sep}`
const pageDir = `${__dirname}${sep}pages${sep}`
// https協(xié)議名
const HTTPS = 'https:'
// 若文件夾不存在則創(chuàng)建
for (const dir of [imgDir, pageDir]) {
if (!fs.existsSync(dir)) {
console.log('文件夾(%s)不存在,即將為您創(chuàng)建', dir)
fs.mkdirSync(dir)
}
}
// const url = 'http://gee2dan.com/'
const url = 'https://www.w3cschool.cn/'
// 下載中的圖片數(shù)量
let downloadingCount = 0
downloadImgsOn(url)
// 下載指定網(wǎng)站包含的圖片
function downloadImgsOn(url) {
// URL作為options
const options = new URL(url);
// 獲取協(xié)議
const protocol = options.protocol
// 根據(jù)協(xié)議選擇發(fā)送請(qǐng)求的模塊
const _http = protocol === HTTPS ? https : http
// 發(fā)送請(qǐng)求
const req = _http.request(options, (res) => {
// 用于存儲(chǔ)返回的html數(shù)據(jù)
let htmlData = ''
res.on('data', (chunk) => {
htmlData += chunk.toString('utf8')
})
res.on('end', () => {
// 將html數(shù)據(jù)存儲(chǔ)到文件中,可用于人工校驗(yàn)
const htmlFileName = `${pageDir}result.html`
fs.writeFile(htmlFileName, htmlData, () => {
console.log('頁(yè)面(%s)讀取完畢,已保存至(%s)', url, htmlFileName)
})
// 將html信息轉(zhuǎn)換為類(lèi)jq對(duì)象
const $ = cheerio.load(htmlData)
const imgs = $('img')
// 用于保存需要下載的圖片url,去除重復(fù)的圖片url
const imgUrlSet = new Set()
imgs.each((index, img) => {
// 獲取圖片url
let imgUrl = img.attribs.src
// 將不完整的圖片url轉(zhuǎn)完成完整的圖片url
if (imgUrl.startsWith('//')) {
imgUrl = protocol + imgUrl
} else if (imgUrl.startsWith('/')) {
imgUrl = url + imgUrl
}
imgUrlSet.add(imgUrl)
})
console.log('獲取圖片url共%s個(gè)', imgUrlSet.size)
// 下載imgUrlSet中包含的圖片s
for (const imgUrl of imgUrlSet) {
downloadImg(imgUrl)
}
})
})
req.on('error', (err) => {
console.error(err)
})
req.end();
}
/**
* 打印當(dāng)前正在下載的圖片數(shù)
*/
function printDownloadingCount() {
console.log('當(dāng)前下載中的圖片有%s個(gè)', downloadingCount)
}
/**
* 下載指定url對(duì)應(yīng)的圖片
* @param {*} imgUrl 目標(biāo)圖片url
* @param {*} maxRetry 下載失敗重試次數(shù)
* @param {*} timeout 超時(shí)時(shí)間毫秒數(shù)
*/
function downloadImg(imgUrl, maxRetry = 10, timeout = 10000) {
/**
* 用于下載失敗后重試
*/
function retry() {
if (maxRetry) {
console.log('(%s)剩余重試次數(shù):%s,即將重試', imgUrl, maxRetry);
downloadImg(imgUrl, maxRetry - 1);
} else {
console.log('(%s)下載徹底失敗', imgUrl)
}
}
// URL作為options
const options = new URL(imgUrl);
// 根據(jù)協(xié)議選擇發(fā)送請(qǐng)求的模塊
const _http = options.protocol === HTTPS ? https : http
// 從url中提取文件名
const matches = imgUrl.match(/(?<=.*\/)[^\/\?]+(?=\?|$)/)
const fileName = matches && matches[0]
// 請(qǐng)求關(guān)閉時(shí)是否需要重新請(qǐng)求
let retryFlag = false
const req = _http.request(options, (res) => {
console.log('開(kāi)始下載圖片(%s)', imgUrl)
downloadingCount += 1
printDownloadingCount()
// 判斷數(shù)據(jù)是否為圖片類(lèi)型,僅保存圖片類(lèi)型
const contentType = res.headers['content-type']
if (contentType.startsWith('image')) {
// 存儲(chǔ)圖片數(shù)據(jù)到內(nèi)存中
const chunks = []
res.on('data', (chunk) => {
chunks.push(chunk)
})
// req.on('abort') 中相同的操作也可以寫(xiě)在 res.on('aborted') 中
// res.on('aborted', () => {})
res.on('end', () => {
downloadingCount -= 1
printDownloadingCount()
// 若響應(yīng)正常結(jié)束,將內(nèi)存中的數(shù)據(jù)寫(xiě)入到文件中
if (res.complete) {
console.log('圖片(%s)下載完成', imgUrl)
write(imgDir + fileName, chunks, 0)
} else {
console.log('(%s)下載結(jié)束但未完成', 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()
}
})
// 如果超時(shí)則中止當(dāng)前請(qǐng)求
req.setTimeout(timeout, () => {
console.log('下載(%s)超時(shí)', imgUrl)
req.abort()
})
req.end()
}
/**
* 將數(shù)據(jù)塊數(shù)組chunks中第index個(gè)數(shù)據(jù)塊寫(xiě)入到distFileName對(duì)應(yīng)文件的末尾
* @param {*} distFileName 數(shù)據(jù)將寫(xiě)入的文件名
* @param {*} chunks 圖片數(shù)據(jù)塊數(shù)組
* @param {*} index 寫(xiě)入數(shù)據(jù)塊的索引
*/
function write(distFileName, chunks, index) {
if (index === 0) {
var i = 0
// 判斷文件是否重名,若重名則重新生成帶序號(hào)的文件名
let tmpFileName = distFileName
while (fs.existsSync(tmpFileName)) {
tmpFileName = distFileName.replace(new RegExp(`^(.*?)([^${sep}\\.]+)(\\..*|$)`), `$1$2_${i}$3`)
i += 1
}
distFileName = tmpFileName
}
// 獲取圖片數(shù)據(jù)塊依次寫(xiě)入文件
const chunk = chunks[index]
if (chunk) {
// 異步、遞歸
fs.appendFile(distFileName, chunk, () => {
write(distFileName, chunks, index + 1)
})
} else {
console.log('文件(%s)寫(xiě)入完畢', distFileName)
}
}
三、注意事項(xiàng)
1、超時(shí)問(wèn)題
下載圖片過(guò)程中偶爾會(huì)出現(xiàn)某個(gè)url長(zhǎng)時(shí)間不響應(yīng)的情況,而http/https模塊不支持在請(qǐng)求超時(shí)時(shí)返回或拋出異常,需要我們手動(dòng)調(diào)用request.abort方法來(lái)中止請(qǐng)求
應(yīng)用中可以結(jié)合request.setTimeout方法實(shí)現(xiàn)請(qǐng)求的超時(shí)控制,例如:
const http = require('http')
options = {
// ...
}
const req = http.request(options, (res) => {
// ...
}
req.setTimeout(1000, () => {
req.abort()
})
2、重新請(qǐng)求
下載圖片的過(guò)程中難免發(fā)生異常。對(duì)此,我們可以引入重新請(qǐng)求的機(jī)制,即在超時(shí)、請(qǐng)求異常等情況發(fā)生時(shí),再次發(fā)起請(qǐng)求,以此提高爬取圖片的成功率。
從章節(jié)二源碼中可以看到,我在事件req.on('abort')和req.on('error')皆執(zhí)行了retryFlag = true來(lái)設(shè)置 重傳標(biāo)志位,并在req.on('close')事件中檢查 重傳標(biāo)志位 以確定是否要發(fā)起重新請(qǐng)求。
那么為什么這樣做可以滿足重新請(qǐng)求的需求?這要從事件的觸發(fā)順序說(shuō)起。
通過(guò)官方文檔 / 中文文檔可以了解到,事件觸發(fā)順序分為以下幾種情況:
a 成功請(qǐng)求
成功的請(qǐng)求中,會(huì)按以下順序觸發(fā)以下事件:
'socket' 事件
-
'response' 事件
-
res對(duì)象上任意次數(shù)的 'data' 事件(如果響應(yīng)主體為空,則根本不會(huì)觸發(fā) 'data' 事件,例如在大多數(shù)重定向中) -
res對(duì)象上的 'end' 事件
-
'close' 事件
b 連接錯(cuò)誤
如果出現(xiàn)連接錯(cuò)誤,則觸發(fā)以下事件:
- 'socket' 事件
- 'error' 事件
- 'close' 事件
c 未連接成功時(shí)中止請(qǐng)求
如果在連接成功之前調(diào)用 req.abort(),則按以下順序觸發(fā)以下事件:
- 'socket' 事件
在這里調(diào)用 req.abort()
- 'abort' 事件
- 'error' 事件并帶上錯(cuò)誤信息 'Error: socket hang up' 和錯(cuò)誤碼 'ECONNRESET'
- 'close' 事件
d 在響應(yīng)階段中止請(qǐng)求
如果在響應(yīng)被接收之后調(diào)用 req.abort(),則按以下順序觸發(fā)以下事件:
'socket' 事件
-
'response' 事件
-
res對(duì)象上任意次數(shù)的 'data' 事件
-
在這里調(diào)用 req.abort()
- 'abort' 事件
-
res對(duì)象上的 'aborted' 事件 - 'close' 事件
-
res對(duì)象上的 'end' 事件 -
res對(duì)象上的 'close' 事件
除了情況 a 外,情況 bcd 都屬于下載失敗的情況,需要重新發(fā)起請(qǐng)求。
通過(guò)觀察不難發(fā)現(xiàn),b 區(qū)別于 a 的事件有 'error' 事件,c 區(qū)別于 a 的事件包括 'abort' 事件和 'error' 事件,而 d 區(qū)別于 a 的事件中也包括 'abort' 事件。
所以只要對(duì) req.on('abort')和req.on('error') 這對(duì)組合進(jìn)行處理,就可以覆蓋 bcd 三種下載失敗的情況。當(dāng)然,組合的情況不唯一,例如 res.on('aborted')和req.on('error')同樣可以滿足需求。
3、圖片去重與重名處理
a 圖片去重
一個(gè)html可能包含若干src相同的img標(biāo)簽,可以在提取img的src時(shí)進(jìn)行歸一化處理,統(tǒng)一格式后再使用Set去重。去重后可以減少請(qǐng)求數(shù)量,提高爬取效率。
b 重名處理
應(yīng)當(dāng)考慮存在 盡管src不同,但文件名相同 的情況,若不處理,同名圖片在寫(xiě)入時(shí)會(huì)發(fā)生覆蓋,引起圖片亂碼和丟失。所以寫(xiě)入前應(yīng)當(dāng)檢查是否存在同名文件,若存在同名文件,則在原文件名后增加唯一的序號(hào),再進(jìn)行寫(xiě)入。以此保證爬取圖片的完整性
謝天謝地 node是單線程的
4、文件寫(xiě)入
從章節(jié)二可以看到,請(qǐng)求獲取的圖片數(shù)據(jù)首先存放在chunks數(shù)組中,響應(yīng)正常結(jié)束后才將chunks中的數(shù)據(jù)寫(xiě)入到文件中。
寫(xiě)入操作分為 同步 和 異步,若采用 同步 方式寫(xiě)入,代碼如下
function write(distFileName, chunks) {
// ...
// 獲取圖片數(shù)據(jù)塊依次寫(xiě)入文件
for (const chunk of chunks) {
fs.appendFileSync(distFileName, chunk)
}
}
章節(jié)二中采用的是 異步 方式。若采用類(lèi)似于 同步 方式的代碼 進(jìn)行異步寫(xiě)入,則無(wú)法保證每一個(gè)數(shù)據(jù)塊的寫(xiě)入順序。故本人采用了一套略微曲折的寫(xiě)法,保證了異步寫(xiě)入的有序性
本以為大費(fèi)周章的采用異步寫(xiě)入,在效率上會(huì)優(yōu)于同步方式。but經(jīng)過(guò)測(cè)試,并沒(méi)有發(fā)現(xiàn)同步與異步的明顯差別,看來(lái)我還是太年輕了
四、運(yùn)行結(jié)果

完