nodejs爬取网页图片

一、思路概述

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经过测试,并没有发现同步与异步的明显差别,看来我还是太年轻了

四、运行结果

image.png

你可能感兴趣的:(nodejs爬取网页图片)