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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342