美文网首页
nodejs爬取网页图片

nodejs爬取网页图片

作者: mosband | 来源:发表于2019-12-12 19:08 被阅读0次

    一、思路概述

    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爬取网页图片

          本文链接:https://www.haomeiwen.com/subject/wndtnctx.html