nodejs爬取網(wǎng)頁(yè)圖片

一、思路概述

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é)果

image.png

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容